From 629b57549f9c51a95e1cc1bfa9593ef567c1c341 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:32:06 -0700 Subject: [PATCH 01/95] STA-1598: Prevent duplicate TOML project keys from breaking Codex startup (#8138) --- src/main/codex/codex-config-mirror.test.ts | 164 +++++++++++++++++++++ src/main/codex/codex-config-mirror.ts | 50 ++++++- src/main/codex/config-toml-trust.test.ts | 54 +++++++ src/main/codex/config-toml-trust.ts | 75 +++++++--- 4 files changed, 317 insertions(+), 26 deletions(-) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 885e2030548..325faf70e15 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -314,6 +314,170 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) + it('deduplicates basic and literal project headers by decoded Windows path', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'c:\\gemini_etl']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."c:\\\\gemini_etl"]', 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain("[projects.'c:\\gemini_etl']") + }) + + it('lets a semantically matching system revocation replace runtime trust', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'c:\\gemini_etl']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."C:/gemini_etl"]', 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('[projects."C:/gemini_etl"]') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it.each([ + { + name: 'drive-letter casing and separators', + runtimePath: 'c:\\work\\repo', + systemPath: 'C:/work/repo' + }, + { + name: 'WSL UNC path', + runtimePath: '\\\\wsl$\\Ubuntu\\home\\u\\proj', + systemPath: '//WSL$/ubuntu/home/u/proj' + }, + { + name: 'server UNC path', + runtimePath: '\\\\server\\share\\proj', + systemPath: '//SERVER/share/proj' + } + ])('deduplicates $name across mirror inputs', ({ runtimePath, systemPath }) => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [`[projects.'${runtimePath}']`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [`[projects."${systemPath}"]`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1) + }) + + it('self-heals duplicate project tables already present in the runtime config', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'c:\\gemini_etl']", + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('[projects."c:\\\\gemini_etl"]') + }) + + it('keeps untrusted when self-healing duplicate project tables', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'C:\\gemini_etl']", + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('keeps untrusted when self-healing duplicate system project tables', () => { + writeFileSync( + getSystemConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'C:\\gemini_etl']", + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('deduplicates a POSIX project path containing a quote and backslash', () => { + const projectPath = '/tmp/with"quote\\and-backslash' + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [`[projects.'${projectPath}']`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [`[projects."/tmp/with\\"quote\\\\and-backslash"]`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1) + }) + it('does not treat TOML table headers inside multiline strings as sections', () => { mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 422b0e79280..1949a703d41 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -15,6 +15,10 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { + normalizeCodexProjectPathForLookup, + parseCodexProjectHeaderPath +} from './config-toml-trust' export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { @@ -160,14 +164,14 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe } function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { - const runtimeSections = getTomlSections(runtimeConfig) + const runtimeSections = deduplicateProjectTomlSections(getTomlSections(runtimeConfig)) const runtimeProjectHeaders = new Set( runtimeSections .filter((section) => isRuntimeProjectTomlSection(section.header)) .map((section) => getTomlSectionHeaderKey(section.header)) ) const systemUntrustedProjectHeaders = new Set( - getTomlSections(systemConfig) + deduplicateProjectTomlSections(getTomlSections(systemConfig)) .filter((section) => isRuntimeProjectTomlSection(section.header)) .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') .map((section) => getTomlSectionHeaderKey(section.header)) @@ -200,8 +204,9 @@ function stripRuntimeOwnedTomlSections( runtimeProjectHeaders = new Set() ): string { const lines = config.split('\n') - const sections = getTomlSections(config) - const firstSectionIndex = sections[0]?.start ?? -1 + const sourceSections = getTomlSections(config) + const sections = deduplicateProjectTomlSections(sourceSections) + const firstSectionIndex = sourceSections[0]?.start ?? -1 const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') return joinTomlBlocks([ preamble, @@ -262,11 +267,44 @@ function isRuntimeHookTrustTomlSection(header: string): boolean { } function isRuntimeProjectTomlSection(header: string): boolean { - return header.trimStart().startsWith('[projects.') + return parseCodexProjectHeaderPath(header) !== null } function getTomlSectionHeaderKey(header: string): string { - return header.trim() + const projectPath = parseCodexProjectHeaderPath(header) + return projectPath === null + ? header.trim() + : `project:${normalizeCodexProjectPathForLookup(projectPath)}` +} + +// Why: hook upsert already removes both quote representations, while its paired +// Windows slash variants are required for Codex 0.140 and must remain distinct. +function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] { + const deduplicated: TomlSection[] = [] + const projectIndexes = new Map() + for (const section of sections) { + if (!isRuntimeProjectTomlSection(section.header)) { + deduplicated.push(section) + continue + } + const key = getTomlSectionHeaderKey(section.header) + const existingIndex = projectIndexes.get(key) + if (existingIndex === undefined) { + projectIndexes.set(key, deduplicated.length) + deduplicated.push(section) + continue + } + const existing = deduplicated[existingIndex] + if ( + existing && + getProjectTrustLevel(existing.block) !== 'untrusted' && + getProjectTrustLevel(section.block) === 'untrusted' + ) { + // Why: revocation must survive self-healing regardless of duplicate order. + deduplicated[existingIndex] = section + } + } + return deduplicated } function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 8be83c8f340..bda4ac3499f 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -1108,6 +1108,60 @@ describe('upsertProjectTrustLevel', () => { expect(updated).not.toContain('trust_level = "untrusted"') }) + it('updates a Codex literal-string Windows project block without duplicating it', () => { + const original = ["[projects.'c:\\gemini_etl']", 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, 'c:\\gemini_etl', 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain("[projects.'c:\\gemini_etl']") + expect(updated).toContain('trust_level = "trusted"') + expect(updated).not.toContain('[projects."c:\\\\gemini_etl"]') + }) + + it.each([ + { + name: 'drive-letter casing and separators', + existingPath: 'c:\\work\\repo', + incomingPath: 'C:/work/repo' + }, + { + name: 'WSL UNC path casing and separators', + existingPath: '\\\\wsl$\\Ubuntu\\home\\u\\proj', + incomingPath: '//WSL$/ubuntu/home/u/proj' + }, + { + name: 'server UNC path casing and separators', + existingPath: '\\\\server\\share\\proj', + incomingPath: '//SERVER/share/proj' + } + ])('matches $name by decoded Windows path value', ({ existingPath, incomingPath }) => { + const original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, incomingPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain(`[projects.'${existingPath}']`) + expect(updated).toContain('trust_level = "trusted"') + }) + + it('matches a literal-string POSIX project path containing a quote and backslash', () => { + const projectPath = '/tmp/with"quote\\and-backslash' + const original = [`[projects.'${projectPath}']`, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, projectPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain(`[projects.'${projectPath}']`) + expect(updated).toContain('trust_level = "trusted"') + }) + it('preserves an already-canonical remote Windows project path', () => { // Why: SSH project paths are resolved on the remote; local realpath would // canonicalize the wrong machine if the same path happens to exist locally. diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 54dc00bc83d..54331900cd1 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -9,7 +9,6 @@ import { } from 'node:fs' 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, @@ -202,7 +201,19 @@ function normalizeWindowsPathSeparators(sourcePath: string): string { } function usesWindowsPathSeparators(sourcePath: string): boolean { - return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\') + return ( + /^[A-Za-z]:[\\/]/.test(sourcePath) || + sourcePath.startsWith('\\\\') || + sourcePath.startsWith('//') + ) +} + +// Why: Codex and Orca can disagree on quote style, separators, and casing for +// the same Windows project, including when the caller targets a remote host. +export function normalizeCodexProjectPathForLookup(projectPath: string): string { + return usesWindowsPathSeparators(projectPath) + ? normalizeWindowsPathSeparators(projectPath).toLowerCase() + : projectPath } export function parseTrustKey(key: string): { @@ -346,12 +357,11 @@ export function upsertProjectTrustLevelInContent( const trustedProjectPath = options?.alreadyCanonical ? projectPath : getCodexCanonicalProjectPath(projectPath) - const headerPattern = buildProjectHeaderPattern(trustedProjectPath) - const match = headerPattern.exec(existing) + const headerLineEnd = findProjectHeaderLineEnd(existing, trustedProjectPath) const eol = existing.includes('\r\n') ? '\r\n' : '\n' const trustLine = `trust_level = "${trustLevel}"` - if (!match) { + if (headerLineEnd === null) { const block = [`[projects."${escapeTomlString(trustedProjectPath)}"]`, trustLine].join(eol) if (existing.length === 0) { return `${block}${eol}` @@ -364,7 +374,6 @@ export function upsertProjectTrustLevelInContent( return `${existing}${separator}${block}${eol}` } - const headerLineEnd = match.index + match[0].length const after = existing.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel @@ -552,20 +561,6 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { return ranges } -function buildProjectHeaderPattern(projectPath: string): RegExp { - const headerPathValues = [projectPath] - if (usesWindowsPathSeparators(projectPath)) { - headerPathValues.push(projectPath.replace(/\//g, '\\'), projectPath.replace(/\\/g, '/')) - } - const headerPaths = [...new Set(headerPathValues)].map((path) => - escapeRegex(escapeTomlString(path)) - ) - return new RegExp( - `(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`, - process.platform === 'win32' ? 'i' : undefined - ) -} - type ParsedTomlString = { value: string endIndex: number @@ -591,6 +586,46 @@ function parseHookStateHeaderKey(line: string): string | null { return index === trimmed.length || trimmed[index] === '#' ? parsedKey.value : null } +export function parseCodexProjectHeaderPath(line: string): string | null { + const trimmed = line.trimStart() + const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) + if (!prefixMatch) { + return null + } + const parsedPath = parseTomlSingleLineString(trimmed, prefixMatch[0].length) + if (!parsedPath) { + return null + } + let index = skipTomlInlineWhitespace(trimmed, parsedPath.endIndex) + if (trimmed[index] !== ']') { + return null + } + index = skipTomlInlineWhitespace(trimmed, index + 1) + return index === trimmed.length || trimmed[index] === '#' ? parsedPath.value : null +} + +function findProjectHeaderLineEnd(content: string, projectPath: string): number | null { + const lookupPath = normalizeCodexProjectPathForLookup(projectPath) + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + const existingPath = isTomlStructuralLine(scanState) ? parseCodexProjectHeaderPath(line) : null + if (existingPath !== null && normalizeCodexProjectPathForLookup(existingPath) === lookupPath) { + return rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + return null + } + cursor = newlineIndex + 1 + } + return null +} + function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { if (line[startIndex] === '"') { return parseTomlBasicSingleLineString(line, startIndex + 1) From c9919e57b3e317736de3c333630cdc7e7bc44ad4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:56:17 -0700 Subject: [PATCH 02/95] fix(remote): harden terminal, relay, and SSH reliability (#8141) Co-authored-by: Orca --- src/main/providers/ssh-git-provider.test.ts | 20 +- src/main/providers/ssh-git-provider.ts | 11 +- ...bile-session-tabs-notify-coalescer.test.ts | 120 +++++++ .../mobile-session-tabs-notify-coalescer.ts | 109 ++++++ .../mobile-subscribe-integration.test.ts | 106 ++++++ src/main/runtime/orca-runtime.test.ts | 40 +-- src/main/runtime/orca-runtime.ts | 42 ++- src/main/runtime/rpc/dispatcher.ts | 2 +- .../e2ee-channel-text-backpressure.test.ts | 98 ++++++ src/main/runtime/rpc/e2ee-channel.ts | 29 +- src/main/runtime/rpc/methods/terminal.ts | 40 ++- .../terminal-geometry-stale-handle.test.ts | 222 +++++++++++++ .../terminal-multiplex-escape-tail.test.ts | 9 +- .../runtime/rpc/terminal-multiplex.test.ts | 217 +++++++++--- .../rpc/terminal-output-batching.test.ts | 33 +- src/main/runtime/rpc/terminal-send.test.ts | 2 +- .../rpc/terminal-subscribe-buffer.test.ts | 125 +++++-- src/main/ssh/relay-protocol.test.ts | 17 + src/main/ssh/relay-protocol.ts | 39 +++ .../ssh/ssh-git-response-stream-reader.ts | 311 ++++++++++++++++++ src/main/ssh/ssh-relay-deploy.ts | 11 +- src/relay/dispatcher.test.ts | 67 ++++ src/relay/dispatcher.ts | 12 +- src/relay/git-handler.ts | 74 ++++- ...-pty-echo-backpressure.integration.test.ts | 309 +++++++++++++++++ .../git-response-stream-ownership.test.ts | 68 ++++ src/relay/git-response-stream.ts | 211 ++++++++++++ src/relay/protocol.ts | 21 ++ src/relay/relay.ts | 23 +- src/relay/rotating-log-writer.test.ts | 161 +++++++++ src/relay/rotating-log-writer.ts | 174 ++++++++++ .../remote-runtime-pty-batching.test.ts | 44 ++- .../remote-runtime-pty-batching.ts | 3 + .../remote-runtime-pty-transport.test.ts | 6 +- .../remote-runtime-pty-transport.ts | 6 + .../src/lib/agent-followup-delivery.test.ts | 110 +++++++ .../src/lib/agent-followup-delivery.ts | 22 +- src/renderer/src/lib/new-workspace.test.ts | 77 ++++- src/renderer/src/lib/new-workspace.ts | 12 +- ...runtime-terminal-frame-drop-resync.test.ts | 236 +++++++++++++ .../remote-runtime-terminal-multiplexer.ts | 110 ++++++- src/shared/remote-runtime-client.ts | 31 +- ...-runtime-shared-control-connection.test.ts | 34 +- ...e-shared-control-keepalive-refresh.test.ts | 98 ++++++ .../remote-runtime-shared-control-requests.ts | 6 +- .../remote-runtime-shared-control-state.ts | 12 + ...ntime-shared-control-subscriptions.test.ts | 150 +++++++++ ...te-runtime-shared-control-subscriptions.ts | 13 +- .../remote-runtime-shared-control-types.ts | 11 + .../ws-outbound-backpressure-queue.test.ts | 128 +++++++ src/shared/ws-outbound-backpressure-queue.ts | 147 +++++++++ 51 files changed, 3793 insertions(+), 186 deletions(-) create mode 100644 src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts create mode 100644 src/main/runtime/mobile-session-tabs-notify-coalescer.ts create mode 100644 src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts create mode 100644 src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts create mode 100644 src/main/ssh/ssh-git-response-stream-reader.ts create mode 100644 src/relay/git-response-pty-echo-backpressure.integration.test.ts create mode 100644 src/relay/git-response-stream-ownership.test.ts create mode 100644 src/relay/git-response-stream.ts create mode 100644 src/relay/rotating-log-writer.test.ts create mode 100644 src/relay/rotating-log-writer.ts create mode 100644 src/renderer/src/lib/agent-followup-delivery.test.ts create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts create mode 100644 src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts create mode 100644 src/shared/remote-runtime-shared-control-subscriptions.test.ts create mode 100644 src/shared/ws-outbound-backpressure-queue.test.ts create mode 100644 src/shared/ws-outbound-backpressure-queue.ts diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 0974367ef81..91c781bf452 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -7,6 +7,7 @@ type MockMultiplexer = { notify: ReturnType onNotification: ReturnType onNotificationByMethod: ReturnType + onDispose: ReturnType dispose: ReturnType isDisposed: ReturnType } @@ -17,6 +18,9 @@ function createMockMux(): MockMultiplexer { notify: vi.fn(), onNotification: vi.fn(), onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), + // Why: requestGitStreamable subscribes to onDispose before awaiting the + // response so it can reject in-flight reassembly if the link drops. + onDispose: vi.fn().mockReturnValue(vi.fn()), dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false) } @@ -320,7 +324,10 @@ describe('SshGitProvider', () => { 'git.exec', { args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - cwd: '/home/user' + cwd: '/home/user', + // Why: exec opts into response streaming so a large stdout is chunked + // onto the bulk lane; old relays ignore the flag. + __streamResponse: true }, { signal: controller.signal, @@ -353,7 +360,8 @@ describe('SshGitProvider', () => { }) expect(mux.request).toHaveBeenCalledWith('git.exec', { args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - cwd: '/home/user/repo' + cwd: '/home/user/repo', + __streamResponse: true }) }) @@ -636,7 +644,10 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledWith('git.diff', { worktreePath: '/home/user/repo', filePath: 'src/index.ts', - staged: true + staged: true, + // Why: opts into response streaming; a small result still comes back as a + // single frame (relay decides), and old relays ignore the flag. + __streamResponse: true }) expect(result).toEqual(diffResult) }) @@ -880,7 +891,8 @@ describe('SshGitProvider', () => { const result = await provider.getBranchDiff('/home/user/repo', 'main') expect(mux.request).toHaveBeenCalledWith('git.branchDiff', { worktreePath: '/home/user/repo', - baseRef: 'main' + baseRef: 'main', + __streamResponse: true }) expect(result).toEqual(diffs) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 0cde62d534f..76fb163909b 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -21,6 +21,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -373,7 +374,7 @@ export class SshGitProvider implements IGitProvider { return this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => - (await this.mux.request('git.diff', { + (await requestGitStreamable(this.mux, 'git.diff', { worktreePath, filePath, staged, @@ -597,7 +598,7 @@ export class SshGitProvider implements IGitProvider { keyOptions.oldPath ?? null ]), async () => - (await this.mux.request('git.branchDiff', { + (await requestGitStreamable(this.mux, 'git.branchDiff', { worktreePath, baseRef, ...options @@ -619,7 +620,7 @@ export class SshGitProvider implements IGitProvider { args.oldPath ?? null ]), async () => - (await this.mux.request('git.commitDiff', { + (await requestGitStreamable(this.mux, 'git.commitDiff', { worktreePath, ...args })) as GitDiffResult @@ -761,8 +762,8 @@ export class SshGitProvider implements IGitProvider { options?: { signal?: AbortSignal; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> { const result = options - ? await this.mux.request('git.exec', { args, cwd }, options) - : await this.mux.request('git.exec', { args, cwd }) + ? await requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) + : await requestGitStreamable(this.mux, 'git.exec', { args, cwd }) return result as { stdout: string stderr: string diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts new file mode 100644 index 00000000000..44216e64d73 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' + +describe('createMobileSessionTabsNotifyCoalescer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces a rapid burst of title/status flips into one trailing emit (repro)', () => { + // Repro of the churn bug: a spinner-in-title agent flips a PTY title many + // times a second. Without coalescing every flip fans out an emit; with it, + // the whole burst settles into a single trailing-edge emit per worktree. + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + coalescer.schedule('worktree-1') + // Each flip lands well inside the trailing window, resetting the timer. + vi.advanceTimersByTime(10) + } + expect(emit).not.toHaveBeenCalled() + + // Let the trailing window elapse after the last flip. + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('force-flushes under sustained churn so the emit is never starved', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + // Keep flipping faster than the trailing window forever; the max-wait cap + // (250ms) must force at least one emit within that budget. + for (let i = 0; i < 100; i++) { + coalescer.schedule('worktree-1') + vi.advanceTimersByTime(30) + } + + expect(emit).toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('keeps per-worktree windows independent', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenCalledWith('worktree-1') + expect(emit).toHaveBeenCalledWith('worktree-2') + }) + + it('cancel() drops a pending notify so a structural emit can supersede it', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + // A structural change (tab add/remove) cancels the coalesced notify because + // it emits immediately elsewhere. + coalescer.cancel('worktree-1') + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) + + it('flush() emits the pending notify immediately', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.flush('worktree-1') + + expect(emit).toHaveBeenCalledTimes(1) + // The timer must not double-fire afterward. + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + }) + + it('flush() is a no-op when nothing is pending', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.flush('worktree-1') + + expect(emit).not.toHaveBeenCalled() + }) + + it('flushAll() drains every pending worktree (subscription close)', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + coalescer.flushAll() + + expect(emit).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('dispose() drops pending timers without emitting', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.dispose() + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts new file mode 100644 index 00000000000..ac6608b71fb --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts @@ -0,0 +1,109 @@ +// Why: spinner-in-title agents (e.g. the Claude Code braille spinner) flip a +// PTY title several times per second. Each flip touched every session.tabs +// snapshot and fanned a fresh emit out to every subscriber, where the ws layer +// JSON.stringifies it per client — O(clients × snapshot size) of churn work +// with no debounce. Clients gate on snapshotVersion freshness, so only the +// newest version per worktree matters; coalescing the intermediate emits is +// safe. Structural changes (tab added/removed/activated) bypass this via an +// immediate flush so they still propagate promptly. + +// Trailing-edge window: title/status is latency-sensitive UI, so this is +// tighter than files.watch's 150ms but looser than native-chat's 40ms. +const SESSION_TABS_FLUSH_MS = 50 +// Force a flush after this long even under sustained churn, so a title that +// keeps spinning never starves the emit indefinitely. +const SESSION_TABS_MAX_WAIT_MS = 250 + +export type MobileSessionTabsNotifyCoalescer = { + // Schedule a coalesced (trailing-edge) notify for a worktree. + schedule: (worktreeId: string) => void + // Cancel any pending notify for a worktree without emitting. Use when an + // immediate emit has already superseded the pending state, or the worktree + // was removed and a stale notify must not fire. + cancel: (worktreeId: string) => void + // Flush a worktree's pending notify now (emit if one is pending). + flush: (worktreeId: string) => void + // Flush every pending worktree now. + flushAll: () => void + // Drop all pending state without emitting (runtime teardown). + dispose: () => void +} + +type PendingNotify = { + timer: ReturnType + firstScheduledAt: number +} + +/** + * Coalesces per-worktree session.tabs notifications on a short trailing-edge + * window. `emit` is invoked once per settled worktree and is expected to read + * the latest snapshot itself, so only the freshest snapshotVersion is ever + * published — dropped intermediate versions are exactly what clients discard. + */ +export function createMobileSessionTabsNotifyCoalescer( + emit: (worktreeId: string) => void +): MobileSessionTabsNotifyCoalescer { + const pending = new Map() + + const clear = (worktreeId: string): void => { + const entry = pending.get(worktreeId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pending.delete(worktreeId) + } + + const fire = (worktreeId: string): void => { + clear(worktreeId) + emit(worktreeId) + } + + const arm = (worktreeId: string): ReturnType => { + const timer = setTimeout(() => fire(worktreeId), SESSION_TABS_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + return timer + } + + return { + schedule(worktreeId: string): void { + const now = Date.now() + const existing = pending.get(worktreeId) + if (existing) { + // Cap total delay so sustained churn can't starve the emit forever. + if (now - existing.firstScheduledAt >= SESSION_TABS_MAX_WAIT_MS) { + fire(worktreeId) + return + } + clearTimeout(existing.timer) + existing.timer = arm(worktreeId) + return + } + pending.set(worktreeId, { timer: arm(worktreeId), firstScheduledAt: now }) + }, + cancel(worktreeId: string): void { + clear(worktreeId) + }, + flush(worktreeId: string): void { + if (pending.has(worktreeId)) { + fire(worktreeId) + } + }, + flushAll(): void { + // Snapshot keys first: fire() deletes from `pending`, and emit may + // schedule new work, so mutating the live map mid-iteration is unsafe. + const worktreeIds = Array.from(pending.keys()) + for (const worktreeId of worktreeIds) { + fire(worktreeId) + } + }, + dispose(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer) + } + pending.clear() + } + } +} diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 3d36716dd5e..6c319271088 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -983,4 +984,109 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) + + describe('session.tabs title/status churn coalescing', () => { + type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void + } + + function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) + } + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The pending coalesced notify was cancelled by the immediate emit, so no + // duplicate trailing emit fires. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state + // still reaches the listener before it is dropped. + unsubscribe() + expect(emits).toHaveLength(1) + }) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b5fb46e500c..4063a65547f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -89,6 +89,13 @@ import { TERMINAL_METHODS } from './rpc/methods/terminal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') +async function waitForMobileSessionTabsEvents( + events: RuntimeMobileSessionTabsResult[], + count: number +): Promise { + await vi.waitFor(() => expect(events).toHaveLength(count)) +} + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, @@ -11336,6 +11343,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;⠴ - Thinking - grok\x07', 101) runtime.onPtyData('laptop-created-pty', '\x1b]0;⠙ - Responding - grok\x07', 102) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) expect(events[0]?.tabs[0]).toEqual( expect.objectContaining({ @@ -11375,6 +11383,7 @@ describe('OrcaRuntimeService', () => { 100 ) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) expect(events[0]?.tabs[0]).toEqual( expect.objectContaining({ @@ -11393,6 +11402,7 @@ describe('OrcaRuntimeService', () => { 101 ) + await waitForMobileSessionTabsEvents(events, 2) expect(events).toHaveLength(2) expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual( expect.objectContaining({ state: 'waiting' }) @@ -11424,6 +11434,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('hook-ping-pty', payload, 101) runtime.onPtyData('hook-ping-pty', payload, 102) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) unsubscribe() @@ -11456,6 +11467,7 @@ describe('OrcaRuntimeService', () => { // The stuck-spinner guard (#1437) must win over the retained hook row. runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101) + await waitForMobileSessionTabsEvents(events, 1) const last = events.at(-1)?.tabs[0] expect(last?.type).toBe('terminal') expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy() @@ -13681,6 +13693,7 @@ describe('OrcaRuntimeService', () => { foregroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -13731,6 +13744,7 @@ describe('OrcaRuntimeService', () => { foregroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -13792,6 +13806,7 @@ describe('OrcaRuntimeService', () => { freshForegroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(getForegroundProcess).toHaveBeenCalledTimes(2) expect(events).toEqual([ @@ -13856,6 +13871,7 @@ describe('OrcaRuntimeService', () => { freshForegroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -15942,19 +15958,8 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude waiting for permission\x07', 124) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ - expect.objectContaining({ - tabs: [ - expect.objectContaining({ - type: 'terminal', - title: 'Claude working', - agentStatus: expect.objectContaining({ - state: 'working', - terminalHandle: laptopTerminal.handle - }) - }) - ] - }), expect.objectContaining({ tabs: [ expect.objectContaining({ @@ -15967,7 +15972,6 @@ describe('OrcaRuntimeService', () => { ] }) ]) - expect(events[1]!.snapshotVersion).toBeGreaterThan(events[0]!.snapshotVersion) unsubscribe() }) @@ -15993,19 +15997,14 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) runtime.onPtyData('laptop-created-pty', '\x1b]0;claude agents\x07', 124) + await waitForMobileSessionTabsEvents(events, 1) expect(events[0]?.tabs[0]).toEqual( - expect.objectContaining({ - type: 'terminal', - agentStatus: expect.objectContaining({ state: 'working' }) - }) - ) - expect(events[1]?.tabs[0]).toEqual( expect.objectContaining({ type: 'terminal', title: 'claude agents' }) ) - expect(events[1]?.tabs[0]).not.toHaveProperty('agentStatus') + expect(events[0]?.tabs[0]).not.toHaveProperty('agentStatus') unsubscribe() }) @@ -17624,6 +17623,7 @@ describe('OrcaRuntimeService', () => { events.length = 0 runtime.onPtyData('headless-pty-2', '\x1b]0;tab-other running\x07', 200) + await waitForMobileSessionTabsEvents(events, 1) const afterPtyData = events.at(-1) expect(afterPtyData?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`) expect(afterPtyData?.activeTabType).toBe('terminal') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f420ebd0031..9625b5e0cb7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -685,6 +685,10 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' +import { + createMobileSessionTabsNotifyCoalescer, + type MobileSessionTabsNotifyCoalescer +} from './mobile-session-tabs-notify-coalescer' import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -2060,6 +2064,13 @@ export class OrcaRuntimeService { { activate: boolean; selectIfNoActiveTab: boolean } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() + // Why: coalesces title/status-driven session.tabs emits so spinner churn + // doesn't fan out (and per-client JSON.stringify) a snapshot several times a + // second. Emit reads the latest snapshot, so only the freshest version ships. + private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = + createMobileSessionTabsNotifyCoalescer((worktreeId) => + this.notifyMobileSessionTabsChangedNow(worktreeId) + ) private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3532,7 +3543,10 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabsChanged(worktreeId) } - private touchMobileSessionSnapshotsForPty(ptyId: string): void { + private touchMobileSessionSnapshotsForPty( + ptyId: string, + options: { immediate?: boolean } = {} + ): void { for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const hasPtyBackedTab = snapshot.tabs.some( (tab) => @@ -3546,7 +3560,15 @@ export class OrcaRuntimeService { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) - this.notifyMobileSessionTabsChanged(worktreeId) + if (options.immediate) { + // Why: readiness/lifecycle changes are structural and must not wait + // behind the title/status coalescing window. + this.notifyMobileSessionTabsChanged(worktreeId) + } else { + // Why: title/status flips several times a second under spinner-in-title + // agents. Coalesce the emit instead of fanning out every version. + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } } } @@ -5208,6 +5230,9 @@ export class OrcaRuntimeService { ): () => void { this.mobileSessionTabListeners.add(listener) return () => { + // Why: flush pending coalesced notifies before dropping this listener so a + // subscriber closing mid-window still receives the latest settled state. + this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(listener) } } @@ -7240,7 +7265,7 @@ export class OrcaRuntimeService { pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) - this.touchMobileSessionSnapshotsForPty(ptyId) + this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -18884,6 +18909,9 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + // Why: drop any pending coalesced notify so a stale snapshot can't + // land after the removed frame. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } @@ -19058,6 +19086,14 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + // Why: structural changes (tab add/remove/activate) must propagate promptly, + // so cancel any pending coalesced title/status notify — this immediate emit + // already reflects the latest snapshot and supersedes it. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId) + } + + private notifyMobileSessionTabsChangedNow(worktreeId: string): void { if (this.mobileSessionTabListeners.size === 0) { return } diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 818ddb92fca..24ff44597b0 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -102,7 +102,7 @@ export class RpcDispatcher { signal?: AbortSignal clientId?: string clientKind?: 'mobile' | 'runtime' - sendBinary?: (bytes: Uint8Array) => void + sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( streamId: number, handler: (frame: TerminalStreamFrame) => void diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts new file mode 100644 index 00000000000..5d0b2d3da92 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { deriveSharedKey, decrypt, encrypt, generateKeyPair } from './e2ee-crypto' + +// Repro for gap (a): the streaming JSON reply path (encryptedReply) had no +// bufferedAmount gate, so a fast producer over a slow link (legacy +// terminal.subscribe, which has NO seq/resync) ballooned ws.bufferedAmount +// without bound. The fix holds replies in order and drains on recovery — never +// dropping a frame (which would recreate the corruption bug on the legacy path). + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string) => { + sent.push(data) + }), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onError = vi.fn() + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady: vi.fn(), + onError, + ...overrides + }) + const sharedKey = deriveSharedKey(clientKeys.secretKey, serverKeys.publicKey) + channel.handleRawMessage( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: publicKeyToBase64(clientKeys.publicKey) }) + ) + channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + return { channel, ws, sharedKey, onError } +} + +/** Fire a streaming reply through the real channel's encryptedReply closure. */ +function emitReply(ctx: ReturnType, payload: string): void { + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply(payload) + }) + ctx.channel.handleRawMessage(encrypt('{"id":"x","method":"status.get"}', ctx.sharedKey)) +} + +describe('E2EE text reply backpressure', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('holds text replies while over the buffer cap and drains them in order', () => { + const ctx = setup() + const baseline = ctx.ws.sent.length // ready + authenticated control frames + + // Simulate a congested socket: bufferedAmount pinned over the 8 MiB cap. + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + + emitReply(ctx, '{"seq":1}') + emitReply(ctx, '{"seq":2}') + emitReply(ctx, '{"seq":3}') + + // Not dropped, not sent yet — parked in order on the queue. + expect(ctx.ws.sent.length).toBe(baseline) + + // Link drains; the queue flushes every reply, in order, none lost. + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + + const replies = ctx.ws.sent.slice(baseline).map((frame) => decrypt(frame, ctx.sharedKey)) + expect(replies).toEqual(['{"seq":1}', '{"seq":2}', '{"seq":3}']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('sends straight through when the socket is not congested', () => { + const ctx = setup() + const baseline = ctx.ws.sent.length + emitReply(ctx, '{"ok":true}') + expect(ctx.ws.sent.length).toBe(baseline + 1) + expect(decrypt(ctx.ws.sent[baseline]!, ctx.sharedKey)).toBe('{"ok":true}') + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 97ce9c5c813..63cc2c0dfcd 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -3,6 +3,10 @@ // handler only sees plaintext JSON, identical to the Unix socket path. import type { WebSocket } from 'ws' import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto' +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../shared/ws-outbound-backpressure-queue' type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' @@ -48,6 +52,11 @@ export class E2EEChannel { ) => void) | null = null private binaryMessageHandler: ((plaintext: Uint8Array) => void) | null = null + // Why: the streaming JSON reply path (e.g. legacy terminal.subscribe) has no + // seq/resync, so it must never drop frames under backpressure. Hold text + // replies in order while bufferedAmount is over the cap and drain as it + // clears; only a wedged link (hard cap) closes the socket for a clean resync. + private textReplyQueue: WsOutboundBackpressureQueue | null = null deviceToken: string | null = null @@ -128,7 +137,7 @@ export class E2EEChannel { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { return } - this.ws.send(encrypt(response, this.sharedKey)) + this.ensureTextReplyQueue().enqueue(encrypt(response, this.sharedKey)) } const encryptedBinaryReply = (response: Uint8Array): boolean => { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { @@ -215,6 +224,22 @@ export class E2EEChannel { this.onReady(this) } + private ensureTextReplyQueue(): WsOutboundBackpressureQueue { + if (!this.textReplyQueue) { + this.textReplyQueue = createWsOutboundBackpressureQueue({ + send: (frame) => this.ws.send(frame), + // Encrypted replies are base64 ASCII strings, so length === byte count. + byteLengthOf: (frame) => frame.length, + getBufferedAmount: () => this.ws.bufferedAmount, + isWritable: () => Boolean(this.sharedKey) && this.ws.readyState === this.ws.OPEN, + // 1013 (Try Again Later): the link is wedged; drop the channel so the + // client reconnects and replays a full snapshot instead of unbounded RSS. + onOverflow: () => this.onError(1013, 'Outbound reply buffer overflow') + }) + } + return this.textReplyQueue + } + private sendEncryptedControl(message: unknown): void { if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) @@ -229,5 +254,7 @@ export class E2EEChannel { this.sharedKey = null this.messageHandler = null this.binaryMessageHandler = null + this.textReplyQueue?.dispose() + this.textReplyQueue = null } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index a750438e03e..ff553f812a7 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1083,7 +1083,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.resizeForClient', params: TerminalResizeForClient, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle (pane's PTY replaced under it) + // must fail with terminal_handle_stale instead of resizing the wrong PTY + // (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1137,7 +1140,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.setDisplayMode', params: TerminalSetDisplayMode, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of mutating the wrong PTY's display + // mode/viewport (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1159,7 +1165,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.restoreFit', params: TerminalHandle, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of reclaiming the wrong PTY back to + // desktop dims (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1180,7 +1189,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.updateViewport', params: TerminalUpdateViewport, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of writing viewport state to the wrong + // PTY (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1221,18 +1233,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array(), seq?: number - ): void => { + ): boolean => { if (closed) { - return + return false } - sendBinary( - encodeTerminalStreamFrame({ - opcode, - streamId, - seq: typeof seq === 'number' ? seq : cursor++, - payload - }) + // Why: Output `seq` is a UTF-16 high-water the client uses for frame-drop + // gap detection, so a seq-less Output chunk must carry the sentinel 0 + // (== "no seq") rather than the cursor value that orders control frames; + // a cursor value would poison the client's expected-seq tracker. + const resolvedSeq = + typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ + const sent = sendBinary( + encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) ) + return sent !== false } const sendStreamError = (streamId: number, message: string): void => { sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) diff --git a/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts new file mode 100644 index 00000000000..c34ff54f848 --- /dev/null +++ b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' + +// Why: the terminal geometry family (resize/setDisplayMode/restoreFit/ +// updateViewport) mutates PTY state. A remote client can hold a handle minted +// for a PTY that was later replaced under the pane (restart/re-spawn bumps +// ptyId/generation). The UNGUARDED resolveLeafForHandle returns the pane's +// CURRENT pty, so a stale client would mutate the wrong (new) PTY — visible as +// geometry corruption on the fresh session (#7718). These methods must use the +// guarded resolveLiveLeafForHandle, which throws terminal_handle_stale instead. + +// Models a stale handle exactly as the runtime does: the unguarded resolver +// silently adopts the replacement PTY ('pty-b'); the guarded resolver throws. +const NEW_PTY_UNDER_PANE = 'pty-b' + +function stubStaleHandleRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + // Unguarded path: returns the pane's current (replaced) PTY — the misroute. + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: NEW_PTY_UNDER_PANE }), + // Guarded path: surfaces the staleness. + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + ...overrides + } as unknown as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +async function expectStale(method: string, params: unknown, mutators: string[]): Promise { + const spies: Record> = {} + for (const name of mutators) { + spies[name] = vi.fn() + } + const runtime = stubStaleHandleRuntime(spies as Partial) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch(makeRequest(method, params)) + + expect(response.ok).toBe(false) + if (response.ok) { + throw new Error(`expected ${method} to reject a stale handle`) + } + expect(response.error.message).toContain('terminal_handle_stale') + // The wrong (replacement) PTY must never be mutated. + for (const name of mutators) { + expect(spies[name]).not.toHaveBeenCalled() + } +} + +describe('terminal geometry family rejects stale handles instead of mutating the wrong PTY', () => { + it('terminal.resizeForClient fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.resizeForClient', + { terminal: 'stale-terminal', mode: 'restore', clientId: 'client-1' }, + ['resizeForClient'] + ) + }) + + it('terminal.setDisplayMode fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.setDisplayMode', + { + terminal: 'stale-terminal', + mode: 'auto', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }, + [ + 'setMobileDisplayMode', + 'applyMobileDisplayMode', + 'updateMobileSubscriberViewport', + 'markMobileActor' + ] + ) + }) + + it('terminal.restoreFit fails with terminal_handle_stale', async () => { + await expectStale('terminal.restoreFit', { terminal: 'stale-terminal' }, [ + 'reclaimTerminalForDesktop' + ]) + }) + + it('terminal.updateViewport fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.updateViewport', + { + terminal: 'stale-terminal', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }, + ['updateMobileViewport', 'updateDesktopViewport'] + ) + }) +}) + +describe('terminal geometry family still mutates the live PTY for a fresh handle', () => { + it('terminal.restoreFit reclaims the resolved PTY when the handle is live', async () => { + const reclaimTerminalForDesktop = vi.fn().mockResolvedValue(true) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + reclaimTerminalForDesktop + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.restoreFit', { terminal: 'live-terminal' }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ restored: true }) + expect(reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-a') + }) + + it('terminal.resizeForClient resizes the resolved PTY when the handle is live', async () => { + const resizeForClient = vi.fn().mockResolvedValue({ cols: 80, rows: 24 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + resizeForClient + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.resizeForClient', { + terminal: 'live-terminal', + mode: 'restore', + clientId: 'client-1' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(resizeForClient).toHaveBeenCalledWith( + 'pty-a', + 'restore', + 'client-1', + undefined, + undefined + ) + }) + + it('terminal.setDisplayMode mutates the resolved PTY when the handle is live', async () => { + const setMobileDisplayMode = vi.fn() + const applyMobileDisplayMode = vi.fn().mockResolvedValue(undefined) + const updateMobileSubscriberViewport = vi.fn() + const markMobileActor = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + setMobileDisplayMode, + applyMobileDisplayMode, + updateMobileSubscriberViewport, + markMobileActor, + getLayout: vi.fn().mockReturnValue({ seq: 42 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.setDisplayMode', { + terminal: 'live-terminal', + mode: 'auto', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(updateMobileSubscriberViewport).toHaveBeenCalledWith('pty-a', 'client-1', { + cols: 80, + rows: 24 + }) + expect(markMobileActor).toHaveBeenCalledWith('pty-a', 'client-1') + expect(setMobileDisplayMode).toHaveBeenCalledWith('pty-a', 'auto') + expect(applyMobileDisplayMode).toHaveBeenCalledWith('pty-a') + expect(response.result).toEqual({ mode: 'auto', seq: 42 }) + }) + + it('terminal.updateViewport updates the resolved PTY when the handle is live', async () => { + const updateMobileViewport = vi.fn().mockResolvedValue({ updated: true, applied: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + updateMobileViewport, + getLayout: vi.fn().mockReturnValue({ seq: 7 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.updateViewport', { + terminal: 'live-terminal', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(updateMobileViewport).toHaveBeenCalledWith('pty-a', 'client-1', { + cols: 80, + rows: 24 + }) + expect(response.result).toEqual({ updated: true, applied: true, seq: 7 }) + }) +}) diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 59e4d2d1b29..24367102745 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -70,14 +70,19 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { waitForTerminal: vi.fn(() => new Promise(() => {})), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 69e956aa155..edb5afc030a 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -77,14 +77,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -199,7 +204,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 5, seq: 4, - payload: encodeTerminalStreamJson({ requestId: 7, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 7, + scrollbackRows: 5000 + }) }) )! ) @@ -291,14 +299,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-stale-multiplex-resize', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -327,8 +340,20 @@ describe('terminal multiplex RPC', () => { await vi.waitFor(() => expect(resizeListener).toBeDefined()) binaryFrames.splice(0) - resizeListener?.({ cols: 90, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 2 }) - resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 }) + resizeListener?.({ + cols: 90, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + resizeListener?.({ + cols: 100, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 3 + }) await vi.waitFor(() => expect(restreamResolves).toHaveLength(2)) restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24 }) @@ -402,14 +427,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-multibyte-output-batch', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -478,9 +508,11 @@ describe('terminal multiplex RPC', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -502,14 +534,19 @@ describe('terminal multiplex RPC', () => { waitForTerminal: vi.fn(() => new Promise(() => {})), sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-multiplex-limited', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -578,8 +615,16 @@ describe('terminal multiplex RPC', () => { serializeTerminalBuffer: vi .fn() .mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 }) - .mockResolvedValueOnce({ data: 'x'.repeat(2 * 1024 * 1024 + 1), cols: 120, rows: 40 }) - .mockResolvedValueOnce({ data: 'budgeted snapshot', cols: 120, rows: 40 }), + .mockResolvedValueOnce({ + data: 'x'.repeat(2 * 1024 * 1024 + 1), + cols: 120, + rows: 40 + }) + .mockResolvedValueOnce({ + data: 'budgeted snapshot', + cols: 120, + rows: 40 + }), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -596,14 +641,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-budgeted-request', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -640,7 +690,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 14, seq: 2, - payload: encodeTerminalStreamJson({ requestId: 55, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 55, + scrollbackRows: 5000 + }) }) )! ) @@ -704,7 +757,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -805,7 +861,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -892,7 +951,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -981,7 +1043,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -992,7 +1057,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-subscribe-output-chunking', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1067,7 +1134,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1078,13 +1148,18 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-on-subscribe', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) - dataListenerRef.current?.('starting shell\r\n', { seq: 16, rawLength: 16 }) + dataListenerRef.current?.('starting shell\r\n', { + seq: 16, + rawLength: 16 + }) resolveSnapshot({ data: '', cols: 120, rows: 40 }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) @@ -1125,7 +1200,12 @@ describe('terminal multiplex RPC', () => { readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + new Promise<{ + data: string + cols: number + rows: number + seq: number + }>((resolve) => { resolveSnapshot = resolve }) ), @@ -1151,7 +1231,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1162,7 +1245,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-covered-by-snapshot', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) @@ -1222,7 +1307,12 @@ describe('terminal multiplex RPC', () => { readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + new Promise<{ + data: string + cols: number + rows: number + seq: number + }>((resolve) => { resolveSnapshot = resolve }) ), @@ -1248,7 +1338,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1259,7 +1352,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-partially-covered-by-snapshot', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) @@ -1319,7 +1414,10 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -1327,7 +1425,9 @@ describe('terminal multiplex RPC', () => { { signal: controller.signal, connectionId: 'conn-phone-multiplex', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1393,14 +1493,19 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-stale-handle', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1484,14 +1589,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-buffered', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1593,14 +1703,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-buffered-multibyte', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1690,7 +1805,11 @@ describe('terminal multiplex RPC', () => { resolveRequestedSnapshot = resolve }) ) - .mockResolvedValueOnce({ data: 'retry snapshot', cols: 120, rows: 40 }), + .mockResolvedValueOnce({ + data: 'retry snapshot', + cols: 120, + rows: 40 + }), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -1710,14 +1829,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-request-overflow', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1754,7 +1878,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 12, seq: 2, - payload: encodeTerminalStreamJson({ requestId: 44, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 44, + scrollbackRows: 5000 + }) }) )! ) diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 2df3dbc7173..df81e05e057 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -52,7 +52,10 @@ describe('terminal output batching', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -78,7 +81,9 @@ describe('terminal output batching', () => { .map((msg) => JSON.parse(msg)) .filter((message) => message.result?.type === 'data') expect(dataMessages).toHaveLength(1) - expect(dataMessages[0]).toMatchObject({ result: { type: 'data', chunk: 'ab' } }) + expect(dataMessages[0]).toMatchObject({ + result: { type: 'data', chunk: 'ab' } + }) runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise @@ -123,7 +128,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -134,7 +142,9 @@ describe('terminal output batching', () => { (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -144,7 +154,10 @@ describe('terminal output batching', () => { const subscribed = messages .map((msg) => JSON.parse(msg)) .find((msg) => msg.result?.type === 'subscribed') - expect(subscribed?.result).toMatchObject({ type: 'subscribed', streamId: expect.any(Number) }) + expect(subscribed?.result).toMatchObject({ + type: 'subscribed', + streamId: expect.any(Number) + }) await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const emitData = dataListenerRef.current @@ -206,7 +219,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -286,7 +302,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const messages: string[] = [] const dispatchPromise = dispatcher.dispatchStreaming( diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index b778f814977..c426513c9f5 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -417,7 +417,7 @@ describe('terminal send RPC', () => { it('routes terminal restore fit through the runtime driver state machine', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true) }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index adad88dded0..f4a70cbeebd 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -40,7 +40,10 @@ describe('terminal subscribe buffering', () => { ), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -77,11 +80,16 @@ describe('terminal subscribe buffering', () => { const messages: string[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }) + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }) + }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) await dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1' }), @@ -104,9 +112,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -122,7 +132,10 @@ describe('terminal subscribe buffering', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -172,7 +185,10 @@ describe('terminal subscribe buffering', () => { cleanupSubscription: vi.fn(), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -209,9 +225,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -230,7 +248,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -241,7 +262,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-binary-limited', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -273,9 +296,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'serialized snapshot\r\n', cols: 100, @@ -298,7 +323,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -309,7 +337,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-binary-serialized-limited', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -375,7 +405,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -386,7 +419,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -446,7 +481,12 @@ describe('terminal subscribe buffering', () => { data: string cols: number rows: number - oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + oscLinks?: { + row: number + startCol: number + endCol: number + uri: string + }[] }) => void)[] = [] const serializeTerminalBuffer = vi .fn() @@ -457,7 +497,12 @@ describe('terminal subscribe buffering', () => { data: string cols: number rows: number - oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + oscLinks?: { + row: number + startCol: number + endCol: number + uri: string + }[] }>((resolve) => { restreamResolves.push(resolve) }) @@ -490,7 +535,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -501,19 +549,38 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-stale-resize', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) await vi.waitFor(() => expect(resizeListener).toBeDefined()) binaryFrames.splice(0) - resizeListener?.({ cols: 90, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 2 }) - resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 }) + resizeListener?.({ + cols: 90, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + resizeListener?.({ + cols: 100, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 3 + }) await vi.waitFor(() => expect(restreamResolves).toHaveLength(2)) const newerOscLinks = [{ row: 0, startCol: 4, endCol: 9, uri: 'https://example.com' }] - restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24, oscLinks: newerOscLinks }) + restreamResolves[1]?.({ + data: 'newer', + cols: 100, + rows: 24, + oscLinks: newerOscLinks + }) await vi.waitFor(() => expect( binaryFrames.some((frame) => { diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index c4e9f089743..459eaef88df 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -8,10 +8,27 @@ import { FrameDecoder, parseJsonRpcMessage, parseUnameToRelayPlatform, + isGitResponseStreamMarker, type JsonRpcRequest, type DecodedFrame } from './relay-protocol' +describe('git response stream marker', () => { + it('accepts only complete non-negative integer metadata', () => { + expect( + isGitResponseStreamMarker({ + __orcaGitResponseStream: { streamId: 1, totalBytes: 1024, chunkCount: 2 } + }) + ).toBe(true) + expect(isGitResponseStreamMarker({ __orcaGitResponseStream: {} })).toBe(false) + expect( + isGitResponseStreamMarker({ + __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } + }) + ).toBe(false) + }) +}) + describe('frame encoding', () => { it('encodes a frame with 13-byte header', () => { const payload = Buffer.from('hello') diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index db57c4d19aa..c05b6c33bfe 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -55,6 +55,45 @@ export const STREAM_CHUNK_SIZE = 256 * 1024 * client. */ export const MAX_CONCURRENT_STREAMS = 16 +// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── + +/** Serialized-JSON size above which the relay chunks a streamable git response + * (diff family + exec) onto the bulk lane instead of one JSON-RPC frame. Mirror + * of the relay-side constant; the client only opts in — the relay owns the + * decision — so this is documentation of the shared contract. */ +export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 + +/** Per-chunk size (serialized-result UTF-8 bytes) for git response streaming. + * The client reassembles by concatenation and does not depend on this value, + * so it stays cross-version safe. */ +export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 + +/** Sentinel the relay returns as the RPC result when the real payload streams + * as git.responseChunk frames. Absent from old relays, so a new client falls + * back to the plain result they return. */ +export type GitResponseStreamMarker = { + __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } +} + +export function isGitResponseStreamMarker(value: unknown): value is GitResponseStreamMarker { + if (typeof value !== 'object' || value === null || !('__orcaGitResponseStream' in value)) { + return false + } + const marker = (value as { __orcaGitResponseStream?: unknown }).__orcaGitResponseStream + if (typeof marker !== 'object' || marker === null) { + return false + } + const fields = marker as Record + return ( + Number.isInteger(fields.streamId) && + (fields.streamId as number) > 0 && + Number.isInteger(fields.totalBytes) && + (fields.totalBytes as number) >= 0 && + Number.isInteger(fields.chunkCount) && + (fields.chunkCount as number) >= 0 + ) +} + // ── JSON-RPC types ────────────────────────────────────────────────── export type JsonRpcRequest = { diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts new file mode 100644 index 00000000000..ef63c7a6dc0 --- /dev/null +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -0,0 +1,311 @@ +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' + +const SENTINEL_STREAM_ID = -1 + +/** Reject if no stream frame (chunk/end/error) arrives within this window, + * reset on each frame. mux.request's own timeout only bounds the fast sentinel + * response; without this, a relay pump that breaks on staleness (which sends no + * responseEnd) while the SSH channel stays up would hang the client forever. */ +const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 + +/** Bound transient buffering of other concurrent streams' chunks while this + * reader awaits its sentinel: every reader sees all git.responseChunk frames + * and can't filter by streamId until its own sentinel resolves. Foreign frames + * are dropped on drain anyway; this just caps the pre-sentinel backlog. */ +const MAX_PENDING_FRAMES = 64 + +export class GitResponseStreamError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +type PendingFrame = + | { kind: 'chunk'; params: Record } + | { kind: 'end'; params: Record } + | { kind: 'error'; params: Record } + +/** + * Request a git method that may return a large payload, opting into response + * streaming so a big diff/exec response is chunked onto the relay's bulk lane + * instead of one JSON-RPC frame (which would head-of-line-block pty.data echo + * on the shared SSH channel). + * + * Cross-version behavior: + * - New relay + big result → returns the stream sentinel; we reassemble chunks. + * - New relay + small result, or old client → plain single-frame result. + * - Old relay (ignores `__streamResponse`) → returns the plain result; the + * marker check fails and we return it directly, i.e. today's behavior. + */ +export function requestGitStreamable( + mux: SshChannelMultiplexer, + method: string, + params: Record, + options?: { + signal?: AbortSignal + /** Bounds only the sentinel request (forwarded to mux.request), like today. */ + timeoutMs?: number + /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ + inactivityTimeoutMs?: number + } +): Promise { + // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a + // chunk that lands in the same dispatch tick as the response is not dropped + // (mirrors readFileViaStream). streamIdRef stays SENTINEL until the sentinel + // resolves; frames are queued until then and drained. + const streamIdRef = { current: SENTINEL_STREAM_ID } + const unsubscribers: (() => void)[] = [] + const cleanup = (): void => { + while (unsubscribers.length > 0) { + try { + unsubscribers.pop()?.() + } catch { + // best-effort + } + } + } + + return new Promise((resolve, reject) => { + const parts: Buffer[] = [] + let expectedSeq = 0 + let receivedBytes = 0 + let totalBytes = 0 + let chunkCount = 0 + let settled = false + let metadataReady = false + const pending: PendingFrame[] = [] + + const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS + let inactivityTimer: ReturnType | null = null + const clearInactivity = (): void => { + if (inactivityTimer) { + clearTimeout(inactivityTimer) + inactivityTimer = null + } + } + // Why: reset on every stream frame so a legitimately long stream is not + // killed, but a wedged stream (no frames arriving) rejects instead of + // hanging the caller forever. + const armInactivity = (): void => { + clearInactivity() + inactivityTimer = setTimeout(() => { + fail( + new GitResponseStreamError( + `Git response stream stalled (>${inactivityMs}ms without data)` + ) + ) + }, inactivityMs) + inactivityTimer.unref?.() + } + + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + try { + mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) + } catch { + // best-effort + } + } + } + const fail = (err: Error): void => { + if (settled) { + return + } + settled = true + clearInactivity() + cancel() + cleanup() + reject(err) + } + const succeed = (value: unknown): void => { + if (settled) { + return + } + settled = true + clearInactivity() + cleanup() + resolve(value) + } + + const handleChunk = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + const seq = p.seq as number + const data = p.data as string + if (typeof seq !== 'number' || typeof data !== 'string') { + fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) + return + } + if (seq !== expectedSeq) { + fail( + new GitResponseStreamError( + `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` + ) + ) + return + } + const decoded = Buffer.from(data, 'base64') + parts.push(decoded) + receivedBytes += decoded.length + expectedSeq += 1 + armInactivity() + // Why: credit-based flow control — the relay caps unacked chunks so a big + // response cannot queue unbounded ahead of interactive pty.data frames. + if (!mux.isDisposed()) { + try { + mux.notify('git.responseAck', { streamId: streamIdRef.current, seq }) + } catch { + // Disposal can race the check; the ACK is best-effort during teardown. + } + } + } + + const handleEnd = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + if (expectedSeq !== chunkCount || receivedBytes !== totalBytes) { + fail( + new GitResponseStreamError( + `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${receivedBytes}/${totalBytes}` + ) + ) + return + } + try { + succeed(JSON.parse(Buffer.concat(parts).toString('utf-8'))) + } catch (err) { + fail( + new GitResponseStreamError( + `Git stream ${streamIdRef.current} JSON parse failed: ${String(err)}` + ) + ) + } + } + + const handleStreamError = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + fail(new Error((p.message as string | undefined) ?? 'git response stream error')) + } + + const drainPending = (): void => { + while (!settled && pending.length > 0) { + const frame = pending.shift()! + if (frame.kind === 'chunk') { + handleChunk(frame.params) + } else if (frame.kind === 'end') { + handleEnd(frame.params) + } else { + handleStreamError(frame.params) + } + } + } + + // Why: pre-sentinel we cannot filter by streamId (our id is unknown yet), so + // every concurrent reader transiently buffers all readers' chunks. Cap the + // backlog by dropping the oldest; foreign frames are dropped on drain anyway, + // and if our own seq-0 were ever dropped the seq check fails loudly rather + // than corrupting. The sentinel normally resolves long before this cap. + const pushPending = (frame: PendingFrame): void => { + pending.push(frame) + if (pending.length > MAX_PENDING_FRAMES) { + pending.shift() + } + } + + unsubscribers.push( + mux.onNotificationByMethod('git.responseChunk', (p) => { + if (!metadataReady) { + pushPending({ kind: 'chunk', params: p }) + return + } + handleChunk(p) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('git.responseEnd', (p) => { + if (!metadataReady) { + pushPending({ kind: 'end', params: p }) + return + } + handleEnd(p) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('git.responseError', (p) => { + if (!metadataReady) { + pushPending({ kind: 'error', params: p }) + return + } + handleStreamError(p) + }) + ) + unsubscribers.push( + mux.onDispose((reason) => { + const err = new Error( + reason === 'connection_lost' + ? 'SSH connection lost, reconnecting...' + : 'Multiplexer disposed' + ) as Error & { code: string } + err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + fail(err) + }) + ) + + if (options?.signal) { + const signal = options.signal + if (signal.aborted) { + const err = new Error('Request was cancelled') as Error & { name: string } + err.name = 'AbortError' + fail(err) + return + } + const onAbort = (): void => { + const err = new Error('Request was cancelled') as Error & { name: string } + err.name = 'AbortError' + fail(err) + } + signal.addEventListener('abort', onAbort, { once: true }) + unsubscribers.push(() => signal.removeEventListener('abort', onAbort)) + } + + // Why: forward only the mux-request options (signal/timeoutMs) and omit them + // entirely when absent, so callers that previously issued a 2-arg + // mux.request keep the same call shape (and their tests). inactivityTimeoutMs + // governs reassembly here, not the sentinel request. + const streamParams = { ...params, __streamResponse: true } + const requestOptions = + options?.signal !== undefined || options?.timeoutMs !== undefined + ? { signal: options.signal, timeoutMs: options.timeoutMs } + : undefined + const requestPromise = requestOptions + ? mux.request(method, streamParams, requestOptions) + : mux.request(method, streamParams) + void requestPromise + .then((result) => { + if (settled) { + return + } + // Old relay / small result: plain single-frame value, no stream follows. + if (!isGitResponseStreamMarker(result)) { + succeed(result) + return + } + const marker = result.__orcaGitResponseStream + totalBytes = marker.totalBytes + chunkCount = marker.chunkCount + streamIdRef.current = marker.streamId + metadataReady = true + // Why: start the inactivity deadline now — mux.request's timeout only + // covered the sentinel; the reassembly phase needs its own guard. + armInactivity() + drainPending() + }) + .catch((err) => fail(err as Error)) + }) +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 78e7dec4443..26501a8371b 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -756,7 +756,12 @@ async function launchRelay( // Fire-and-forget via conn.exec: we don't need the output — the socket // poll below detects readiness. const logFile = `${remoteDir}/relay.log` - const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} > ${shellEscape(logFile)} 2>&1 ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) @@ -1052,6 +1057,10 @@ function windowsRelayLaunchCommand( quoted(sockPath), '--endpoint-dir', quoted(endpointDir), + // Why: in-process rotation owns relay.log (the tail-diagnostics target); + // the shell redirects remain for pre-JS boot/crash output. + '--log-file', + quoted(logFile), `1>${quoted(logFile)}`, `2>${quoted(errFile)}` ].join(' ') diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5ab997c9d21..0a9a3908172 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -396,6 +396,73 @@ describe('RelayDispatcher', () => { expect(listener).toHaveBeenCalledWith(1) }) + it('detaches the primary client when its write throws (frame lost, trigger reconnect)', () => { + // Regression: a primary-client write throw dropped the frame (possibly + // pty.data/pty.exit) with no resend AND without notifying detach, so the + // owning Orca's reconnect + PTY-reattach path never engaged until the ~20s + // keepalive timeout — output/pane-death were silently lost in the meantime. + let throwOnWrite = false + const detachDispatcher = new RelayDispatcher((data) => { + if (throwOnWrite) { + throw new Error('socket closed') + } + written.push(Buffer.from(data)) + }) + try { + const detachListener = vi.fn() + detachDispatcher.onClientDetached(detachListener) + + // A frame the owning Orca must not silently miss (e.g. a pane exit). + throwOnWrite = true + detachDispatcher.notify('pty.exit', { id: 'pty-1', code: 0 }) + + // Fix: the write failure detaches the primary so the reconnect/reattach + // machinery runs promptly instead of waiting for keepalive timeout. + expect(detachListener).toHaveBeenCalledWith(1) + + // Recovery: a reconnecting socket swaps the write via setWrite; the client + // is usable again and later frames flow to the new sink. + throwOnWrite = false + const recovered: Buffer[] = [] + detachDispatcher.setWrite((data) => { + recovered.push(Buffer.from(data)) + }) + detachDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' }) + expect(recovered.length).toBeGreaterThan(0) + } finally { + detachDispatcher.dispose() + } + }) + + it('aborts in-flight primary requests when a client write throws', () => { + let throwOnWrite = false + const detachDispatcher = new RelayDispatcher(() => { + if (throwOnWrite) { + throw new Error('socket closed') + } + }) + let requestSignal: AbortSignal | undefined + try { + detachDispatcher.onRequest('slow.method', async (_params, context) => { + requestSignal = context.signal + await new Promise((resolve) => { + context.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + detachDispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: 9, method: 'slow.method' }, 1, 0) + ) + + expect(requestSignal?.aborted).toBe(false) + throwOnWrite = true + detachDispatcher.notify('pty.exit', { id: 'pty-1', code: 0 }) + + expect(requestSignal?.aborted).toBe(true) + } finally { + detachDispatcher.dispose() + } + }) + describe('notifyBulk (bulk lane backpressure)', () => { it('resolves immediately when the sink accepts the frame', async () => { const frames: Buffer[] = [] diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 427d90e6ac0..7dfdf5dc5f4 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -536,11 +536,21 @@ export class RelayDispatcher { } catch (err) { client.closed = true client.generation++ + this.requestAborts.abortClient(client.id) this.flushDrainWaiters(client) + // Why: a write throw means this frame (possibly pty.data or pty.exit) was + // lost with no resend — the framing carries seq/ack but no retransmit + // buffer. Detach so the owning Orca's reconnect + PTY-reattach path runs + // promptly (regenerating dropped pty.data from the replay buffer and pane + // death via reattach-not-found), instead of silently dropping frames until + // the ~20s keepalive timeout notices. The primary client stays in the map + // (its object is reused across setWrite reconnects) but detach listeners + // must still fire, exactly as invalidateClient() does for stdin/stdout + // death — this makes the socket-write-throw path consistent with those. if (client !== this.primaryClient) { this.clients.delete(client.id) - this.notifyClientDetached(client.id) } + this.notifyClientDetached(client.id) process.stderr.write( `[relay] Client write failed: ${err instanceof Error ? err.message : String(err)}\n` ) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index c878dfd563e..3bdeb860c61 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -61,6 +61,8 @@ import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' +import { GitResponseStreamRegistry } from './git-response-stream' +import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -171,6 +173,9 @@ function execFileWithStdin( export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + // Why: large diff/exec responses are chunked onto the bulk lane so they do + // not head-of-line-block interactive pty.data echo on the shared SSH channel. + private readonly responseStreams = new GitResponseStreamRegistry() // Why: configured submodule paths change rarely; an instance-level TTL cache // avoids re-reading `.gitmodules` on every diff click over SSH, and being @@ -182,6 +187,13 @@ export class GitHandler { constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher this.registerHandlers() + // Why: a detached client's git.responseAck frames will never arrive; wake + // any pump parked on the ack window so it re-checks staleness and exits. + this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) + } + + dispose(): void { + this.responseStreams.disposeAll() } private registerHandlers(): void { @@ -190,7 +202,7 @@ export class GitHandler { this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) - this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) + this.dispatcher.onRequest('git.diff', (p, context) => this.getDiff(p, context)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p)) @@ -215,8 +227,8 @@ export class GitHandler { this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) - this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) - this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) + this.dispatcher.onRequest('git.branchDiff', (p, context) => this.branchDiff(p, context)) + this.dispatcher.onRequest('git.commitDiff', (p, context) => this.commitDiff(p, context)) this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) @@ -231,6 +243,45 @@ export class GitHandler { this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context)) this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) + this.dispatcher.onNotification('git.responseAck', (p, context) => this.responseAck(p, context)) + this.dispatcher.onNotification('git.cancelResponseStream', (p, context) => + this.cancelResponseStream(p, context) + ) + } + + private responseAck(params: Record, context: RequestContext): void { + const streamId = params.streamId + const seq = params.seq + if (typeof streamId === 'number' && typeof seq === 'number') { + this.responseStreams.recordAck(streamId, seq, context.clientId) + } + } + + private cancelResponseStream(params: Record, context: RequestContext): void { + const streamId = params.streamId + if (typeof streamId === 'number') { + this.responseStreams.abort(streamId, context.clientId) + } + } + + // Why: when the client opted into response streaming and the serialized result + // exceeds the threshold, chunk it onto the bulk lane and return a small + // sentinel as the RPC result. Old clients omit the flag (single-frame, as + // today); old relays never call this, so a new client falls back to the plain + // result they return. + private maybeStreamResponse( + result: unknown, + params: Record, + context: RequestContext | undefined + ): unknown { + if (params.__streamResponse !== true || !context) { + return result + } + const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') + if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { + return result + } + return this.responseStreams.startStream(payload, this.dispatcher, context) } private async runWithDiffDedupeClear(run: () => Promise): Promise { @@ -352,7 +403,7 @@ export class GitHandler { }) } - private async getDiff(params: Record) { + private async getDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string // Why: filePath is relative to worktreePath and used in readWorkingFile via @@ -366,7 +417,7 @@ export class GitHandler { const compareAgainstHead = params.compareAgainstHead as boolean | undefined // Why: register the in-flight dedupe synchronously (before any await) so // concurrent identical reads coalesce; submodule routing happens inside. - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => { // Why: gitlink paths can't be read as blobs and submodule working dirs @@ -431,6 +482,7 @@ export class GitHandler { ) } ) + return this.maybeStreamResponse(result, params, context) } private async stage(params: Record) { @@ -1027,7 +1079,7 @@ export class GitHandler { } } - private async branchDiff(params: Record) { + private async branchDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string if (baseRef.startsWith('-')) { @@ -1038,7 +1090,7 @@ export class GitHandler { filePath: params.filePath as string | undefined, oldPath: params.oldPath as string | undefined } - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', worktreePath, @@ -1056,9 +1108,10 @@ export class GitHandler { options ) ) + return this.maybeStreamResponse(result, params, context) } - private async commitDiff(params: Record) { + private async commitDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const args = { commitOid: params.commitOid as string, @@ -1066,7 +1119,7 @@ export class GitHandler { filePath: params.filePath as string, oldPath: params.oldPath as string | undefined } - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'commitDiff', worktreePath, @@ -1077,6 +1130,7 @@ export class GitHandler { ]), () => commitDiffEntry(this.gitBuffer.bind(this), worktreePath, args) ) + return this.maybeStreamResponse(result, params, context) } private async exec(params: Record, context?: RequestContext) { @@ -1085,7 +1139,7 @@ export class GitHandler { validateGitExecArgs(args) const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) - return { stdout, stderr } + return this.maybeStreamResponse({ stdout, stderr }, params, context) } private async clone(params: Record, context?: RequestContext) { diff --git a/src/relay/git-response-pty-echo-backpressure.integration.test.ts b/src/relay/git-response-pty-echo-backpressure.integration.test.ts new file mode 100644 index 00000000000..09e6bf38192 --- /dev/null +++ b/src/relay/git-response-pty-echo-backpressure.integration.test.ts @@ -0,0 +1,309 @@ +/** + * Regression: SSH typing latency under large git responses. + * + * The relay and the client share ONE ordered SSH channel. A large git diff/exec + * response sent as a single JSON-RPC frame queues megabytes into the outbound + * pipe at once; an interactive pty.data echo emitted mid-response then sits + * behind all of it and typing feels seconds-slow. + * + * These tests model the SSH channel as a congestible in-memory pipe and assert + * deterministic byte bounds instead of wall-clock latency: + * - WITHOUT the fix (client does not opt in), the whole response queues ahead + * of a pty echo; + * - WITH the fix (client opts in), the response streams on the bulk lane and + * at most ~1 chunk frame sits ahead of the echo; + * - both paths reassemble the identical git result. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { GIT_RESPONSE_CHUNK_SIZE } from './protocol' + +// One framed git.responseChunk: base64 (4/3) + JSON envelope + header slack. +const FRAMED_CHUNK_BYTES = Math.ceil((GIT_RESPONSE_CHUNK_SIZE * 4) / 3) + 512 +const SINK_HIGH_WATER_MARK = 64 * 1024 + +async function waitUntil( + predicate: () => boolean, + what: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitUntil timed out: ${what}`) + } + await new Promise((r) => setImmediate(r)) + } +} + +async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { + let last = read() + let stable = 0 + while (stable < stableTurns) { + await new Promise((r) => setImmediate(r)) + const current = read() + if (current === last) { + stable += 1 + } else { + stable = 0 + last = current + } + } +} + +type Harness = { + mux: SshChannelMultiplexer + dispatcher: RelayDispatcher + gitHandler: GitHandler + queuedBytes: () => number + deliverAll: () => void + startAutoDeliver: () => void + dispose: () => void +} + +function createHarness(opts: { congested: boolean }): Harness { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → relay (keystrokes, acks) is the opposite duplex direction and + // is not blocked by relay→client congestion. + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + + const outQueue: Buffer[] = [] + let queuedBytes = 0 + const drainWaiters = new Set<() => void>() + const fireDrainIfIdle = (): void => { + if (queuedBytes > 0) { + return + } + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + } + + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + outQueue.push(data) + queuedBytes += data.length + if (!opts.congested) { + return true + } + return queuedBytes < SINK_HIGH_WATER_MARK + }, + { + waitWriteDrain: (cb: () => void) => { + drainWaiters.add(cb) + fireDrainIfIdle() + } + } + ) + relayFeed = (data: Buffer) => dispatcher.feed(data) + + const deliverAll = (): void => { + while (outQueue.length > 0) { + const buf = outQueue.shift()! + queuedBytes -= buf.length + for (const cb of clientDataCallbacks) { + cb(buf) + } + } + fireDrainIfIdle() + } + + let autoDeliverTimer: ReturnType | null = null + const startAutoDeliver = (): void => { + if (autoDeliverTimer) { + return + } + autoDeliverTimer = setInterval(deliverAll, 1) + } + + const context = new RelayContext() + const gitHandler = new GitHandler(dispatcher, context) + const mux = new SshChannelMultiplexer(clientTransport) + + return { + mux, + dispatcher, + gitHandler, + queuedBytes: () => queuedBytes, + deliverAll, + startAutoDeliver, + dispose: () => { + if (autoDeliverTimer) { + clearInterval(autoDeliverTimer) + } + mux.dispose() + dispatcher.dispose() + gitHandler.dispose() + } + } +} + +// Build a repo whose staged diff for `big.txt` is several MB so git.diff +// returns a payload well over the stream threshold. +function makeRepoWithLargeStagedDiff(dir: string): void { + const env = { ...process.env } + const run = (args: string[]): void => { + execFileSync('git', args, { cwd: dir, stdio: 'pipe', env }) + } + run(['init']) + run(['config', 'user.email', 'test@test.com']) + run(['config', 'user.name', 'Test']) + // Distinct non-repeating lines keep the diff from compressing to nothing. + // Stay under the render limits (120k lines / 6M chars) so the diff result + // carries the full ~4MB content and exceeds the stream threshold. + const lines: string[] = [] + for (let i = 0; i < 12_000; i += 1) { + lines.push(`line ${i} ${'x'.repeat(100)}`) + } + writeFileSync(path.join(dir, 'big.txt'), lines.join('\n')) + run(['add', 'big.txt']) +} + +describe('large git response vs pty.data echo head-of-line blocking', () => { + let tmpDir: string + let repoDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-hol-')) + repoDir = path.join(tmpDir, 'repo') + mkdirSync(repoDir, { recursive: true }) + makeRepoWithLargeStagedDiff(repoDir) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('WITHOUT opt-in: the whole diff queues ahead of a pty echo (single-frame HOL)', async () => { + const harness = createHarness({ congested: true }) + try { + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + // Plain request WITHOUT __streamResponse: single-frame response, as today. + const diffPromise = harness.mux.request('git.diff', { + worktreePath: repoDir, + filePath: 'big.txt', + staged: true + }) + // Let the whole single JSON-RPC frame land in the congested pipe. + await waitUntil(() => harness.queuedBytes() > SINK_HIGH_WATER_MARK, 'diff frame queued') + await waitUntilSettled(() => harness.queuedBytes()) + + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // The echo sits behind the entire (multi-hundred-KB) diff frame — far + // more than a single bulk chunk would be. + expect(queuedBytesAheadOfEcho).toBeGreaterThan(512 * 1024) + + harness.startAutoDeliver() + const result = (await diffPromise) as { diff?: string } + expect(typeof result).toBe('object') + } finally { + harness.dispose() + } + }, 30_000) + + it('WITH opt-in: the diff streams on the bulk lane and the echo stays bounded', async () => { + const harness = createHarness({ congested: true }) + try { + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + const diffPromise = requestGitStreamable(harness.mux, 'git.diff', { + worktreePath: repoDir, + filePath: 'big.txt', + staged: true + }) + // Deliver the sentinel response, then let the pump run into congestion. + await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') + harness.deliverAll() + await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') + await waitUntilSettled(() => harness.queuedBytes()) + + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // At most one in-flight bulk frame (the write that saturated the sink) + // plus slack sits ahead of the echo. + expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) + + harness.startAutoDeliver() + const streamed = (await diffPromise) as Record + expect(typeof streamed).toBe('object') + expect(streamed).not.toHaveProperty('__orcaGitResponseStream') + } finally { + harness.dispose() + } + }, 30_000) + + it('streamed result equals the single-frame result', async () => { + const harness = createHarness({ congested: false }) + try { + harness.startAutoDeliver() + const params = { worktreePath: repoDir, filePath: 'big.txt', staged: true } + // Why: request both concurrently so the shared 1ms delivery pump drives + // the plain frame and the streamed chunk/ack round-trips without either + // starving the other on a slow CI event loop. + const [plain, streamed] = await Promise.all([ + harness.mux.request('git.diff', params), + requestGitStreamable(harness.mux, 'git.diff', params) + ]) + expect(streamed).toEqual(plain) + } finally { + harness.dispose() + } + }, 30_000) + + it('rejects with an inactivity timeout when the stream stalls after the sentinel', async () => { + const harness = createHarness({ congested: false }) + try { + // Deliver the sentinel so reassembly begins, then stop delivering chunks + // to model a relay pump that wedged while the channel stayed up. Without + // the client-side inactivity deadline this promise would hang forever. + const streamedPromise = requestGitStreamable( + harness.mux, + 'git.diff', + { worktreePath: repoDir, filePath: 'big.txt', staged: true }, + { inactivityTimeoutMs: 250 } + ) + await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') + harness.deliverAll() // sentinel only; chunks stay undelivered + await expect(streamedPromise).rejects.toThrow(/stalled/) + } finally { + harness.dispose() + } + }, 30_000) +}) diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts new file mode 100644 index 00000000000..c6340265238 --- /dev/null +++ b/src/relay/git-response-stream-ownership.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { GitResponseStreamRegistry } from './git-response-stream' +import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' + +async function flushPump(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('GitResponseStreamRegistry client ownership', () => { + const registries: GitResponseStreamRegistry[] = [] + + afterEach(() => { + for (const registry of registries) { + registry.disposeAll() + } + registries.length = 0 + }) + + it('ignores acknowledgements and cancellation from a different relay client', async () => { + const ownerClientId = 7 + const notifyBulk = vi.fn().mockResolvedValue(undefined) + const dispatcher = { + notifyBulk, + notify: vi.fn() + } as unknown as RelayDispatcher + const context: RequestContext = { + clientId: ownerClientId, + isStale: () => false + } + const registry = new GitResponseStreamRegistry() + registries.push(registry) + const payload = Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS * 3)) + const marker = registry.startStream(payload, dispatcher, context) + const streamId = marker.__orcaGitResponseStream.streamId + + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + + registry.recordAck(streamId, 10_000, ownerClientId + 1) + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + + registry.abort(streamId, ownerClientId + 1) + registry.recordAck(streamId, STREAM_ACK_WINDOW_CHUNKS - 1, ownerClientId) + await flushPump() + expect(notifyBulk.mock.calls.length).toBeGreaterThan(STREAM_ACK_WINDOW_CHUNKS) + }) + + it('contains a secondary failure while reporting a stream error', async () => { + const notifyBulk = vi.fn().mockRejectedValue(new Error('socket closed')) + const dispatcher = { notifyBulk, notify: vi.fn() } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry() + registries.push(registry) + + registry.startStream(Buffer.from('payload'), dispatcher, { + clientId: 7, + isStale: () => false + }) + + await flushPump() + await flushPump() + + expect(notifyBulk).toHaveBeenCalledTimes(2) + expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') + expect(notifyBulk.mock.calls[1]?.[0]).toBe('git.responseError') + }) +}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts new file mode 100644 index 00000000000..bced3b47e46 --- /dev/null +++ b/src/relay/git-response-stream.ts @@ -0,0 +1,211 @@ +// Streams large git RPC responses (diff family + exec) onto the bulk lane in +// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block +// interactive pty.data echo on the shared SSH channel. Mirrors the fs +// read-stream credit-window pattern (see fs-handler-file-read.ts) but the +// payload is an in-memory serialized string rather than a file handle. +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + GIT_RESPONSE_CHUNK_SIZE, + STREAM_ACK_WINDOW_CHUNKS, + STREAM_ACK_STALL_RECHECK_MS, + type GitResponseStreamMarker +} from './protocol' + +type GitResponseStreamEntry = { + ownerClientId: number + aborted: boolean + /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ + ackedThroughSeq: number + ackWaiters: Set<() => void> +} + +/** Serialized git responses are chunked as base64 so multi-byte UTF-8 + * sequences never split across a chunk boundary (the client concatenates the + * decoded bytes and parses once). */ +function encodeChunks(payload: Buffer): string[] { + const chunks: string[] = [] + for (let offset = 0; offset < payload.length; offset += GIT_RESPONSE_CHUNK_SIZE) { + chunks.push(payload.subarray(offset, offset + GIT_RESPONSE_CHUNK_SIZE).toString('base64')) + } + return chunks +} + +export class GitResponseStreamRegistry { + private streams = new Map() + private nextId = 1 + + private register(ownerClientId: number): number { + const streamId = this.nextId++ + this.streams.set(streamId, { + ownerClientId, + aborted: false, + ackedThroughSeq: -1, + ackWaiters: new Set() + }) + return streamId + } + + recordAck(streamId: number, seq: number, clientId: number): void { + const entry = this.streams.get(streamId) + if ( + !entry || + entry.ownerClientId !== clientId || + typeof seq !== 'number' || + !Number.isFinite(seq) + ) { + return + } + if (seq > entry.ackedThroughSeq) { + entry.ackedThroughSeq = seq + } + this.wake(entry) + } + + abort(streamId: number, clientId: number): void { + const entry = this.streams.get(streamId) + if (entry?.ownerClientId === clientId) { + entry.aborted = true + this.wake(entry) + } + } + + /** Wake every parked pump so it re-checks staleness — used when a client + * detaches and its acks will never arrive. */ + wakeAll(): void { + for (const entry of this.streams.values()) { + this.wake(entry) + } + } + + private wake(entry: GitResponseStreamEntry): void { + for (const waiter of Array.from(entry.ackWaiters)) { + waiter() + } + } + + private waitForAck(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry || entry.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + entry.ackWaiters.delete(finish) + resolve() + } + const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS) + timer.unref?.() + entry.ackWaiters.add(finish) + }) + } + + /** + * Register a stream for `payload`, kick off the bulk-lane pump on a later + * task (so the sentinel response reaches the client first), and return the + * sentinel marker to send as the RPC result. + */ + startStream( + payload: Buffer, + dispatcher: RelayDispatcher, + context: RequestContext + ): GitResponseStreamMarker { + const streamId = this.register(context.clientId) + const chunks = encodeChunks(payload) + // Why: kick the pump off the response task so the client sees the sentinel + // (and can subscribe/reassemble) before the first chunk frame arrives. + setImmediate(() => { + void this.pump(streamId, chunks, dispatcher, context) + }) + return { + __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } + } + } + + private async pump( + streamId: number, + chunks: string[], + dispatcher: RelayDispatcher, + context: RequestContext + ): Promise { + const entry = this.streams.get(streamId) + if (!entry) { + return + } + const clientId = context.clientId + let seq = 0 + let endReason: 'end' | 'aborted' | 'stale' = 'end' + try { + for (seq = 0; seq < chunks.length; seq += 1) { + if (context.isStale()) { + endReason = 'stale' + break + } + if (entry.aborted) { + endReason = 'aborted' + break + } + // Why: credit window — the client acks each chunk, bounding how many + // bulk bytes a keystroke echo can queue behind on the shared channel. + while ( + seq - entry.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS && + !context.isStale() && + !entry.aborted + ) { + await this.waitForAck(streamId) + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (entry.aborted) { + endReason = 'aborted' + break + } + // Why: notifyBulk waits out sink saturation so chunk frames never pile + // up in the outbound pipe ahead of interactive pty.data frames. + await dispatcher.notifyBulk( + 'git.responseChunk', + { streamId, seq, data: chunks[seq] }, + { + clientId + } + ) + } + if (endReason === 'end') { + await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) + } + } catch (err) { + if (!context.isStale() && !entry.aborted) { + try { + await dispatcher.notifyBulk( + 'git.responseError', + { + streamId, + message: err instanceof Error ? err.message : String(err) + }, + { clientId } + ) + } catch { + // Why: the original failure may be the owning channel closing; a + // second send failure must not escape this detached pump. + } + } + } finally { + this.streams.delete(streamId) + } + } + + disposeAll(): void { + for (const entry of this.streams.values()) { + entry.aborted = true + this.wake(entry) + } + this.streams.clear() + } +} diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 267f6ef480f..4df0f7bd9b5 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -59,6 +59,27 @@ export const STREAM_ACK_WINDOW_CHUNKS = 4 * (and its open file handle) forever. */ export const STREAM_ACK_STALL_RECHECK_MS = 1_000 +// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── + +/** Serialized-JSON size above which a streamable git response (diff family + + * exec) is chunked onto the bulk lane instead of one JSON-RPC frame, so a large + * diff cannot head-of-line-block interactive pty.data echo on the shared SSH + * channel. Below this, single-frame is cheaper and avoids stream overhead. */ +export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 + +/** Per-chunk size (UTF-8 bytes of the serialized result) for git response + * streaming. Independent from STREAM_CHUNK_SIZE — this offset math is not + * shared with fs streams, so tuning it here is cross-version safe as long as + * the client reassembles by concatenation (it does not depend on chunk size). */ +export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 + +/** Sentinel result returned in place of a large git response: the real payload + * follows as git.responseChunk frames on the bulk lane. Old relays never emit + * this, so a new client falls back to the plain result they return. */ +export type GitResponseStreamMarker = { + __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } +} + export const RelayErrorCode = { TooManyStreams: -33006, StreamProtocolError: -33007 diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 65f90e88628..d47c2019582 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -37,6 +37,7 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' +import { installRelayLogRotation } from './rotating-log-writer' import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' import { ExternalAutomationsHandler } from './external-automations-handler' @@ -114,6 +115,7 @@ function parseArgs(argv: string[]): { cliMode: boolean sockPath: string endpointDir?: string + logFile?: string } { let graceTimeMs = DEFAULT_GRACE_MS let connectMode = false @@ -121,6 +123,7 @@ function parseArgs(argv: string[]): { let cliMode = false let sockPath = '' let endpointDir: string | undefined + let logFile: string | undefined for (let i = 2; i < argv.length; i++) { if (argv[i] === '--grace-time' && argv[i + 1]) { const parsed = Number.parseInt(argv[i + 1], 10) @@ -143,12 +146,15 @@ function parseArgs(argv: string[]): { } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { endpointDir = argv[i + 1] i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFile = argv[i + 1] + i++ } } if (!sockPath) { sockPath = join(process.cwd(), SOCK_NAME) } - return { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } + return { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir, logFile } } // ── Connect mode ───────────────────────────────────────────────────── @@ -318,7 +324,7 @@ async function readOrcaCliStdin(): Promise { // ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } = parseArgs( + const { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir, logFile } = parseArgs( process.argv ) @@ -332,6 +338,13 @@ async function main(): Promise { return } + // Why: only the long-lived detached daemon accumulates relay.log; --connect + // bridges and --orca-cli are short-lived and already returned above. Route all + // relay logging through a size-capped rotator so relay.log can't grow forever. + if (detached && logFile) { + installRelayLogRotation(logFile) + } + let ownsSocketPath = false let ownedSocketIdentity: SocketIdentity | null = null const ownsCurrentSocketPath = (): boolean => { @@ -454,10 +467,7 @@ async function main(): Promise { const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) const fsHandler = new FsHandler(dispatcher, context) - // Why: GitHandler registers its own request handlers on construction, - // so we hold the reference only for potential future disposal. - const _gitHandler = new GitHandler(dispatcher, context) - void _gitHandler + const gitHandler = new GitHandler(dispatcher, context) const _preflightHandler = new PreflightHandler(dispatcher) const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) @@ -1013,6 +1023,7 @@ async function main(): Promise { dispatcher.dispose() ptyHandler.dispose() fsHandler.dispose() + gitHandler.dispose() hookServer.stop() // Why: Node's Unix server.close() can unlink the listen path. If the path // was externally removed and rebound by a newer relay, closing this older diff --git a/src/relay/rotating-log-writer.test.ts b/src/relay/rotating-log-writer.test.ts new file mode 100644 index 00000000000..61c988766bb --- /dev/null +++ b/src/relay/rotating-log-writer.test.ts @@ -0,0 +1,161 @@ +/** + * Regression: relay.log grows unbounded on long-lived relays. + * + * The relay is launched detached with `> relay.log 2>&1`, which truncates only + * at relaunch; a relay that stays up for days accumulates per-stream stderr + * lines forever. These tests assert the in-process rotator caps size, keeps one + * archived generation, and always leaves the CURRENT log at relay.log so the + * `tail -100 relay.log` diagnostics workflow keeps working. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, existsSync, statSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +import { RotatingLogWriter, installRelayLogRotation } from './rotating-log-writer' + +describe('RotatingLogWriter', () => { + let dir: string + let logPath: string + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'relay-log-rot-')) + logPath = path.join(dir, 'relay.log') + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('rotates relay.log -> relay.log.1 at the cap and keeps the current log tail-able', () => { + const cap = 4 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'a'.repeat(200)}\n` + // Write well past the cap so at least one rotation happens. + for (let i = 0; i < 60; i += 1) { + writer.write(line) + } + + // Current log exists at relay.log (tail target) and is under the cap. + expect(existsSync(logPath)).toBe(true) + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + // Exactly one archived generation. + expect(existsSync(`${logPath}.1`)).toBe(true) + expect(existsSync(`${logPath}.2`)).toBe(false) + + // The most recent lines are in the current log (tail-ability). + writer.write('MARKER-LAST\n') + expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') + } finally { + writer.dispose() + } + }) + + it('preserves pre-existing boot output already in relay.log (append, not truncate)', () => { + writeFileSync(logPath, 'BOOT-LINE-FROM-SHELL-REDIRECT\n') + const writer = new RotatingLogWriter(logPath, 1024 * 1024) + try { + writer.write('runtime line\n') + const contents = readFileSync(logPath, 'utf-8') + expect(contents).toContain('BOOT-LINE-FROM-SHELL-REDIRECT') + expect(contents).toContain('runtime line') + } finally { + writer.dispose() + } + }) + + it('bounds a single oversized log write and keeps its newest tail', () => { + const cap = 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + writer.write(`${'old'.repeat(1000)}LATEST-CONTEXT`) + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + expect(readFileSync(logPath, 'utf-8')).toContain('LATEST-CONTEXT') + } finally { + writer.dispose() + } + }) + + it('caps total footprint to ~2x maxBytes (current + one archive)', () => { + const cap = 8 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'z'.repeat(256)}\n` + for (let i = 0; i < 500; i += 1) { + writer.write(line) + } + const currentSize = statSync(logPath).size + const archiveSize = existsSync(`${logPath}.1`) ? statSync(`${logPath}.1`).size : 0 + // Never more than the current file + a single archived generation. + expect(currentSize).toBeLessThanOrEqual(cap) + expect(archiveSize).toBeLessThanOrEqual(cap) + expect(existsSync(`${logPath}.2`)).toBe(false) + } finally { + writer.dispose() + } + }) + + it('caps size via truncate-in-place when rename cannot succeed (Windows shell-handle case)', () => { + // Model the platform where renameSync fails (e.g. Windows, where the launch + // shell's own `1>relay.log` handle blocks renaming the live file): make the + // archive path a directory so renameSync(logPath, `${logPath}.1`) throws. + mkdirSync(`${logPath}.1`, { recursive: true }) + const cap = 4 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'q'.repeat(200)}\n` + for (let i = 0; i < 100; i += 1) { + writer.write(line) + } + // The cap still holds via truncate-in-place even though no archive was made. + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + writer.write('MARKER-LAST\n') + expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') + } finally { + writer.dispose() + } + }) + + it('installRelayLogRotation routes stdout/stderr through the rotator and restores', () => { + const cap = 2 * 1024 + const { restore } = installRelayLogRotation(logPath, cap) + try { + process.stderr.write('via-stderr-line\n') + process.stdout.write('via-stdout-line\n') + expect(readFileSync(logPath, 'utf-8')).toContain('via-stderr-line') + expect(readFileSync(logPath, 'utf-8')).toContain('via-stdout-line') + } finally { + restore() + } + // After restore, process.stderr no longer targets the rotator file. Spy so + // the assertion write does not leak to the real test-runner stderr. + const sizeAfterRestore = statSync(logPath).size + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + process.stderr.write('should-not-be-in-relay-log\n') + } finally { + spy.mockRestore() + } + expect(statSync(logPath).size).toBe(sizeAfterRestore) + }) + + it('leaves the original streams active when the log cannot be opened', () => { + mkdirSync(logPath) + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const { writer, restore } = installRelayLogRotation(logPath) + expect(writer.active).toBe(false) + process.stdout.write('stdout fallback\n') + process.stderr.write('stderr fallback\n') + expect(stdout).toHaveBeenCalledWith('stdout fallback\n') + expect(stderr).toHaveBeenCalledWith('stderr fallback\n') + restore() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }) +}) diff --git a/src/relay/rotating-log-writer.ts b/src/relay/rotating-log-writer.ts new file mode 100644 index 00000000000..e685e6fd520 --- /dev/null +++ b/src/relay/rotating-log-writer.ts @@ -0,0 +1,174 @@ +// Size-capped, crash-safe rotation for the relay's diagnostic log. +// +// Why: the relay is launched detached with `> relay.log 2>&1`, which only +// truncates at relaunch — a long-lived relay's relay.log grows unbounded +// (per-stream stderr lines, reconnect flaps, etc.). Rotation must live in the +// relay process because it owns its own stderr; the shell redirect cannot cap +// size. Constraints honored: +// - keeps working when the launch fd is a pipe/redirect on Linux/macOS/Windows +// (append-only, no fd tricks; a rotation failure falls back to the prior fd); +// - the diagnostics tail (`tail -100 ~/.orca-remote/relay-*/relay.log`) keeps +// working because the CURRENT log is always at relay.log; +// - crash-safe: writes and rotation are guarded so logging never throws, and +// rotation renames (never deletes the live file) so no window loses logging. +import { closeSync, openSync, renameSync, statSync, writeSync } from 'node:fs' + +/** Default size cap before rotating relay.log → relay.log.1. 10 MB balances + * keeping enough history for diagnosis against bounding disk use on small + * remote hosts (e.g. a Raspberry Pi); one archived generation is kept. */ +export const DEFAULT_RELAY_LOG_MAX_BYTES = 10 * 1024 * 1024 + +type StreamWrite = typeof process.stderr.write + +export class RotatingLogWriter { + private readonly logPath: string + private readonly rotatedPath: string + private readonly maxBytes: number + private fd: number | null = null + private currentBytes = 0 + private failed = false + + constructor(logPath: string, maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES) { + this.logPath = logPath + this.rotatedPath = `${logPath}.1` + this.maxBytes = maxBytes + this.open() + } + + private open(mode: 'a' | 'w' = 'a'): void { + try { + // 'a' preserves pre-JS boot output already in relay.log and keeps + // concurrent appends atomic; 'w' truncates in place (used as the rotation + // fallback when a rename cannot succeed — e.g. Windows, where the launch + // shell's own redirect handle blocks renaming the live file). + this.fd = openSync(this.logPath, mode) + try { + this.currentBytes = mode === 'w' ? 0 : statSync(this.logPath).size + } catch { + this.currentBytes = 0 + } + } catch { + // Cannot open the log file (permission/full disk): disable rotation and + // let callers fall back to the original stream. + this.failed = true + this.fd = null + } + } + + /** True when the writer is usable; false means fall back to raw stderr. */ + get active(): boolean { + return !this.failed && this.fd !== null + } + + write(chunk: string | Uint8Array): void { + if (!this.active || this.fd === null) { + return + } + const encoded = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk) + // Why: one pathological log write must not bypass the disk cap. Preserve + // the newest tail because it carries the most useful failure context. + const buf = + encoded.length > this.maxBytes ? encoded.subarray(encoded.length - this.maxBytes) : encoded + try { + // Rotate BEFORE writing when the incoming write would cross the cap, so a + // single large line still lands wholly in the fresh file. + if (this.currentBytes > 0 && this.currentBytes + buf.length > this.maxBytes) { + this.rotate() + } + writeSync(this.fd, buf) + this.currentBytes += buf.length + } catch { + // A write failure must never crash the relay; disable and fall back. + this.failed = true + this.closeQuietly() + } + } + + private rotate(): void { + if (this.fd !== null) { + closeSync(this.fd) + this.fd = null + } + let renamed = false + try { + // Preferred path: archive one generation. rename() replaces any existing + // relay.log.1 atomically on POSIX; the live file is renamed (never + // deleted) so no log window is lost, then we reopen a fresh relay.log. + renameSync(this.logPath, this.rotatedPath) + renamed = true + } catch { + // Why: on Windows the launch shell holds its own `1>relay.log` handle + // WITHOUT FILE_SHARE_DELETE, so renaming the live file fails (EPERM/EBUSY) + // — and a cross-device relay.log.1 would fail too. Fall back to truncating + // relay.log in place ('w') so the size cap STILL holds and we don't churn a + // failing rename on every subsequent line. The shell's fd keeps appending + // at its own offset, but the truncation bounds total growth. Trade-off: no + // archived generation on this platform/path. + } + // Successful rename → fresh append file (size 0). Failed rename → truncate. + this.open(renamed ? 'a' : 'w') + } + + private closeQuietly(): void { + if (this.fd !== null) { + try { + closeSync(this.fd) + } catch { + // best-effort + } + this.fd = null + } + } + + dispose(): void { + this.closeQuietly() + } +} + +/** + * Route process.stdout/stderr writes through a RotatingLogWriter that owns + * `logPath`. Returns a restore function. If the writer cannot open the file, + * the original streams are left untouched (logging still works, just uncapped). + */ +export function installRelayLogRotation( + logPath: string, + maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES +): { writer: RotatingLogWriter; restore: () => void } { + const writer = new RotatingLogWriter(logPath, maxBytes) + const originalStdout = process.stdout.write.bind(process.stdout) + const originalStderr = process.stderr.write.bind(process.stderr) + + if (!writer.active) { + return { writer, restore: () => {} } + } + + const wrap = + (original: StreamWrite): StreamWrite => + (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + writer.write(chunk) + // Why: preserve the Writable.write callback contract so callers awaiting + // the write (rare, but e.g. flush-before-exit) are not left hanging. + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb + if (typeof callback === 'function') { + ;(callback as (err?: Error | null) => void)(null) + } + // If the writer went inactive mid-run (write failure), fall back so logs + // are not silently dropped for the rest of the session. + if (!writer.active) { + return (original as (c: string | Uint8Array) => boolean)(chunk) + } + return true + } + + process.stdout.write = wrap(originalStdout as StreamWrite) as typeof process.stdout.write + process.stderr.write = wrap(originalStderr as StreamWrite) as typeof process.stderr.write + + return { + writer, + restore: () => { + process.stdout.write = originalStdout as typeof process.stdout.write + process.stderr.write = originalStderr as typeof process.stderr.write + writer.dispose() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts index 881dfa435b0..d07bd2900af 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' -import { createRemoteRuntimePtyTextBatcher } from './remote-runtime-pty-batching' +import { + createRemoteRuntimePtyTextBatcher, + createRemoteRuntimeViewportBatcher +} from './remote-runtime-pty-batching' describe('createRemoteRuntimePtyTextBatcher', () => { it('coalesces small input until the debounce flush', async () => { @@ -139,3 +142,42 @@ describe('createRemoteRuntimePtyTextBatcher', () => { } }) }) + +describe('createRemoteRuntimeViewportBatcher', () => { + it('drops the queued viewport on clear so a later flush emits nothing', () => { + vi.useFakeTimers() + try { + const resizes: { cols: number; rows: number }[] = [] + const batcher = createRemoteRuntimeViewportBatcher(33, (cols, rows) => { + resizes.push({ cols, rows }) + }) + + batcher.queue(120, 40) + batcher.clear() + // A stale pending viewport left behind by clear() would leak out here. + batcher.flush() + + expect(resizes).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit a cleared viewport when the debounce timer would have fired', () => { + vi.useFakeTimers() + try { + const resizes: { cols: number; rows: number }[] = [] + const batcher = createRemoteRuntimeViewportBatcher(33, (cols, rows) => { + resizes.push({ cols, rows }) + }) + + batcher.queue(90, 30) + batcher.clear() + vi.advanceTimersByTime(100) + + expect(resizes).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index 48a37e4b9d7..33561b06953 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -173,6 +173,9 @@ export function createRemoteRuntimeViewportBatcher( clearTimeout(timer) timer = null } + // Why: also drop the queued viewport so a later flush()/reuse can't emit a + // stale resize after the batcher was cleared on teardown/resubscribe. + pending = null } const flush = (): void => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 69805980928..de741deedeb 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1990,9 +1990,9 @@ describe('createRemoteRuntimePtyTransport', () => { emitOutput(streamId, 'live-after-overflow') expect(onReplayData).not.toHaveBeenCalled() - expect(onError).toHaveBeenCalledWith( - 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' - ) + // Why: an oversized snapshot is skipped but live output continues, so the + // transport classifies it as benign and never surfaces a fatal red banner. + expect(onError).not.toHaveBeenCalled() expect(onConnect).toHaveBeenCalled() expect(onData).toHaveBeenCalledWith('live-after-overflow', expect.objectContaining({ seq: 1 })) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index e9a8f361f5b..b4eea73e0d7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -21,6 +21,7 @@ import { } from '../../runtime/runtime-terminal-stream' import { getRemoteRuntimeTerminalMultiplexer, + REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE, type RemoteRuntimeMultiplexedTerminal } from '../../runtime/remote-runtime-terminal-multiplexer' import { @@ -373,6 +374,11 @@ export function createRemoteRuntimePtyTransport( function handleRemoteTerminalError(error: unknown): void { const message = runtimeTerminalErrorMessage(error) + if (message === REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE) { + // Why: an oversized initial snapshot is skipped but live output keeps + // flowing — informational, not fatal, so never surface a red xterm banner. + return + } if (isRemoteTerminalGoneMessage(message)) { // Why: paired web clients consume host-published PTY handles. If the host // retires one between snapshots, clear this mirror and wait for the next diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts new file mode 100644 index 00000000000..207c70ada36 --- /dev/null +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: vi.fn(), + sendRuntimePtyInputVerified: vi.fn() +})) + +// The interpreter-wrapped agents that deliver their prompt over stdin after the +// process starts. These are pip console-scripts, so the PTY foreground comm is +// python/python3 — never the agent's own name. +const INTERPRETER_WRAPPED_AGENTS = [ + { agent: 'aider', expectedProcess: TUI_AGENT_CONFIG.aider.expectedProcess }, + { agent: 'mistral-vibe', expectedProcess: TUI_AGENT_CONFIG['mistral-vibe'].expectedProcess } +] as const + +describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('globalThis', globalThis) + // Deliver the prompt write eagerly so the test does not depend on retries. + vi.mocked(sendRuntimePtyInputVerified).mockResolvedValue(true) + }) + + it('sanity: config keeps aider/vibe as stdin-after-start with python-style expected process', () => { + expect(TUI_AGENT_CONFIG.aider.promptInjectionMode).toBe('stdin-after-start') + expect(TUI_AGENT_CONFIG['mistral-vibe'].promptInjectionMode).toBe('stdin-after-start') + }) + + for (const { agent, expectedProcess } of INTERPRETER_WRAPPED_AGENTS) { + it(`types the prompt once ${agent} is up behind a python3 wrapper with a live child`, async () => { + // The console-script agent is running: foreground comm is python3 and the + // PTY has a non-shell child. The exact agent name never appears. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'python3', + hasChildProcesses: true + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + }) + + it(`still refuses to type into a bare ${agent} shell foreground`, async () => { + // No agent yet: foreground is a plain shell with no non-shell child. The + // guard must NOT write user text into an arbitrary shell. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(false) + expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + + it(`refuses to type into a ${agent} wrapper without a live child`, async () => { + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'python3', + hasChildProcesses: false + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(false) + expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + } + + it('types immediately when the resolver already returns the agent name (local ps path)', async () => { + // On local desktop the ps-table resolver usually resolves python3 → aider + // before we poll; the exact-match path must keep working. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'aider', + hasChildProcesses: true + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess: 'aider', + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + }) +}) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index d5a4c6ff9b9..0042cb0f2e4 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -2,7 +2,11 @@ import { inspectRuntimeTerminalProcess, sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' -import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import { + isAgentForegroundWrapperProcess, + isExpectedAgentProcess +} from '../../../shared/agent-process-recognition' +import { isShellProcess } from '../../../shared/shell-process-detection' import type { GlobalSettings } from '../../../shared/types' type RuntimeOwnerSettings = Pick | null | undefined @@ -25,7 +29,7 @@ export async function sendFollowupPromptWhenAgentReady(args: { } // Why: delayed follow-ups must not type into an arbitrary shell. Require a -// positive expected-process match before writing user/task text to the PTY. +// positive readiness signal before writing user/task text to the PTY. async function waitForAgentForeground( ptyId: string, expectedProcess: string, @@ -41,6 +45,20 @@ async function waitForAgentForeground( if (isExpectedAgentProcess(foreground, expectedProcess)) { return true } + // Why: interpreter-wrapped agents (aider, mistral-vibe are pip console + // scripts) surface a python/node foreground comm, so the exact-name check + // never matches — locally when the ps-table resolver can't pin the child, + // and over SSH when the relay falls back to the bare interpreter name. If + // the foreground is a known agent wrapper (not a shell) with a live + // non-shell child, the agent has taken over the PTY and can accept input. + if ( + attempt >= 4 && + isAgentForegroundWrapperProcess(foreground) && + !isShellProcess(foreground) && + process.hasChildProcesses + ) { + return true + } } catch { // Ignore transient PTY inspection failures and keep polling. } diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 8306ca0d62c..dc70d24841f 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -4,6 +4,7 @@ const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, mockPasteDraftToAgentPtyWhenReady, + mockShowAutomationPromptNotSentToast, mockTrack, store, storeListeners, @@ -12,6 +13,7 @@ const { mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), mockPasteDraftToAgentPtyWhenReady: vi.fn(), + mockShowAutomationPromptNotSentToast: vi.fn(), mockTrack: vi.fn(), storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), startupLeafId: '11111111-1111-4111-8111-111111111111', @@ -88,6 +90,10 @@ vi.mock('@/lib/telemetry', () => ({ track: mockTrack })) +vi.mock('@/lib/agent-background-session-timeout-toast', () => ({ + showAutomationPromptNotSentToast: mockShowAutomationPromptNotSentToast +})) + import { ensureAgentStartupInTerminal, getSetupConfig, @@ -298,6 +304,65 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) + it('surfaces the not-sent toast when a follow-up prompt is dropped', async () => { + // Foreground never becomes a recognized agent and there is no live child, + // so the readiness wait times out and the prompt is not delivered. + mockInspectRuntimeTerminalProcess.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('aider') + }) + + it('does not toast when a follow-up prompt is delivered', async () => { + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + expect(mockShowAutomationPromptNotSentToast).not.toHaveBeenCalled() + }) + + it('passes an onTimeout that surfaces the not-sent toast to the draft paste path', async () => { + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'review this before sending' + } + }) + + const call = mockPasteDraftToAgentPtyWhenReady.mock.calls.at(-1)?.[0] as + | { onTimeout?: () => void } + | undefined + expect(call?.onTimeout).toBeTypeOf('function') + call?.onTimeout?.() + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('claude') + }) + it('does not track when follow-up prompt delivery rejects', async () => { mockSendRuntimePtyInputVerified.mockRejectedValue(new Error('runtime timeout')) @@ -335,7 +400,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'pty-1', content: 'review this before sending', agent: 'claude', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) @@ -378,7 +444,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'agent-pty', content: 'Linear context draft', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) @@ -434,7 +501,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'pty-delayed', content: 'https://github.com/stablyai/orca/pull/2051', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) @@ -515,7 +583,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'startup-pty', content: 'linked draft', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 149c0e7708f..0a7505762d0 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -4,6 +4,7 @@ import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft' import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' +import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { @@ -302,12 +303,17 @@ async function deliverAgentStartupToTerminal( // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await sendFollowupPromptWhenAgentReady({ + const delivered = await sendFollowupPromptWhenAgentReady({ ptyId, expectedProcess: startup.expectedProcess, prompt: startup.followupPrompt, settings: runtimeSettings }) + // Why: a dropped follow-up is otherwise silent — surface the same toast the + // draft path uses so the user knows to open the workspace and paste it. + if (!delivered) { + showAutomationPromptNotSentToast(startup.agent) + } } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the @@ -321,7 +327,9 @@ async function deliverAgentStartupToTerminal( agent: startup.agent, // Why: startup.draftPrompt is only attached after native draft launch // planning is unavailable, so this paste is the first delivery attempt. - forcePaste: true + forcePaste: true, + // Why: surface a dropped draft instead of silently losing it. + onTimeout: () => showAutomationPromptNotSentToast(startup.agent) }) } } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts new file mode 100644 index 00000000000..daebefb6e73 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { + getRemoteRuntimeTerminalMultiplexer, + resetRemoteRuntimeTerminalMultiplexersForTests, + type RemoteRuntimeMultiplexedTerminal +} from './remote-runtime-terminal-multiplexer' + +// Why: reproduces the silent frame-drop corruption. The server multiplex path +// drops Output frames when the websocket buffer is over its cap +// (encryptedBinaryReply returns false); the wire `seq` is an output high-water, so +// a drop leaves a detectable gap. This harness drives the real client +// multiplexer through the same subscribe transport the app uses and forces a +// drop, asserting the client resyncs instead of rendering a corrupt tail. + +type SubscribeCallbacks = { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void +} + +/** + * Minimal server that mimics src/main/runtime/rpc/methods/terminal.ts's multiplex + * output path: Output frames carry a monotonic UTF-16 high-water `seq`, and a + * SnapshotRequest is answered with an initial-style snapshot (no requestId). + */ +class FakeMultiplexServer { + private cursorUnits = 0 + private streamId = 0 + dropNextOutput = false + droppedFrames = 0 + holdNextManualSnapshot = false + snapshotRequests: (number | undefined)[] = [] + private heldManualRequestId: number | null = null + private snapshotData = 'INITIAL' + + constructor( + private readonly toClient: (bytes: Uint8Array) => void, + private readonly onServerSideDrop?: () => void + ) {} + + /** Client -> server frames arrive here (Subscribe / SnapshotRequest / Input). */ + receive(bytes: Uint8Array): void { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + const payload = decodeTerminalStreamJson<{ streamId: number }>(frame.payload) + this.streamId = payload?.streamId ?? 0 + this.sendSnapshot() + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = decodeTerminalStreamJson<{ requestId?: number }>(frame.payload) + this.snapshotRequests.push(payload?.requestId) + if (typeof payload?.requestId === 'number' && this.holdNextManualSnapshot) { + this.holdNextManualSnapshot = false + this.heldManualRequestId = payload.requestId + return + } + // Resync request: the server serializes the *current* buffer, so recovery + // includes everything the client missed. + this.snapshotData = 'RECOVERED' + this.sendSnapshot(payload?.requestId) + } + } + + private send(opcode: TerminalStreamOpcode, payload: Uint8Array, seq: number): void { + this.toClient(encodeTerminalStreamFrame({ opcode, streamId: this.streamId, seq, payload })) + } + + private sendSnapshot(requestId?: number): void { + this.send( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ cols: 80, rows: 24, seq: this.cursorUnits, requestId }), + 0 + ) + this.send(TerminalStreamOpcode.SnapshotChunk, encodeTerminalStreamText(this.snapshotData), 0) + this.send(TerminalStreamOpcode.SnapshotEnd, new Uint8Array(), 0) + } + + /** Emit an Output chunk, honoring simulated websocket backpressure. */ + output(text: string): void { + const startSeq = this.cursorUnits + this.cursorUnits += text.length + if (this.dropNextOutput) { + // encryptedBinaryReply returned false: frame is NOT sent. The byte + // high-water still advances (server keeps producing), so the next frame's + // seq jumps past what the client last saw. + this.dropNextOutput = false + this.droppedFrames += 1 + this.onServerSideDrop?.() + return + } + void startSeq + this.send(TerminalStreamOpcode.Output, encodeTerminalStreamText(text), this.cursorUnits) + } + + flushHeldManualSnapshot(): void { + if (this.heldManualRequestId === null) { + throw new Error('No manual snapshot is held') + } + const requestId = this.heldManualRequestId + this.heldManualRequestId = null + this.snapshotData = 'MANUAL' + this.sendSnapshot(requestId) + } +} + +describe('remote terminal frame-drop resync', () => { + const unsubscribe = vi.fn() + let server: FakeMultiplexServer + + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeTerminalMultiplexersForTests() + + const subscribe = vi.fn(async (_args: unknown, callbacks: SubscribeCallbacks) => { + server = new FakeMultiplexServer((bytes) => callbacks.onBinary?.(bytes)) + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { + unsubscribe, + sendBinary: (bytes: Uint8Array) => server.receive(bytes) + } + }) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { subscribe } } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + async function subscribeClient(): Promise<{ + data: string[] + snapshots: string[] + stream: RemoteRuntimeMultiplexedTerminal + }> { + const data: string[] = [] + const snapshots: string[] = [] + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-1') + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + callbacks: { + onData: (chunk) => data.push(chunk), + onSnapshot: (chunk) => snapshots.push(chunk) + } + }) + // Let the initial snapshot round-trip settle. + await Promise.resolve() + await Promise.resolve() + return { data, snapshots, stream } + } + + it('detects a dropped Output frame via the seq gap and resyncs', async () => { + const { data, snapshots } = await subscribeClient() + expect(snapshots).toEqual(['INITIAL']) + + server.output('aaa') + server.dropNextOutput = true + server.output('bbb') // dropped under backpressure — never reaches the client + server.output('ccc') // seq jumps past 'bbb', exposing the gap + + // Flush the client's resync SnapshotRequest -> server snapshot round-trip. + await Promise.resolve() + await Promise.resolve() + + // The corrupt tail ('ccc', which followed a gap) is NOT rendered as live data. + expect(data).toEqual(['aaa']) + expect(server.droppedFrames).toBe(1) + // Instead, a fresh authoritative snapshot recovers the terminal. + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) + + it('passes contiguous output straight through without resyncing', async () => { + const { data, snapshots } = await subscribeClient() + + server.output('one') + server.output('two') + server.output('three') + await Promise.resolve() + await Promise.resolve() + + expect(data).toEqual(['one', 'two', 'three']) + expect(snapshots).toEqual(['INITIAL']) + }) + + it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => { + const { data, snapshots } = await subscribeClient() + + server.output('é') + server.dropNextOutput = true + server.output('🙂') + server.output('界') + await Promise.resolve() + await Promise.resolve() + + expect(data).toEqual(['é']) + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) + + it('defers recovery until an in-flight manual snapshot finishes', async () => { + const { data, snapshots, stream } = await subscribeClient() + server.holdNextManualSnapshot = true + const manualSnapshot = stream.serializeBuffer({ scrollbackRows: 100 }) + await Promise.resolve() + + server.output('aaa') + server.dropNextOutput = true + server.output('🙂') + server.output('ccc') + + expect(data).toEqual(['aaa']) + expect(server.snapshotRequests).toHaveLength(1) + + server.flushHeldManualSnapshot() + await Promise.resolve() + await Promise.resolve() + + await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) + expect(server.snapshotRequests).toHaveLength(2) + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) +}) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 70fb729b9db..5f0b751a91f 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -77,6 +77,13 @@ type RemoteRuntimeMultiplexedTerminalState = { snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null + // Why: Output frames carry a UTF-16 offset high-water `seq`; a jump past the + // expected next offset means the server dropped frames under backpressure. + // Track it so a gap triggers a self-healing snapshot resync instead of + // silently rendering corrupt/missing output (frame-drop resync). + expectedSeq: number | undefined + resyncInFlight: boolean + resyncPendingSend: boolean } type RemoteRuntimeSnapshotInfo = { @@ -111,7 +118,9 @@ type RemoteRuntimeSnapshotRequest = { const CONTROL_STREAM_ID = 0 const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024 const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 -const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = +// Why: exported so the transport can classify it as benign — the snapshot was +// skipped but live output continues, so it must not surface a fatal red banner. +export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' class RemoteRuntimeTerminalMultiplexer { @@ -149,7 +158,10 @@ class RemoteRuntimeTerminalMultiplexer { snapshotTarget: 'initial', snapshotInfo: null, initialSnapshotReceived: false, - pendingSnapshotRequest: null + pendingSnapshotRequest: null, + expectedSeq: undefined, + resyncInFlight: false, + resyncPendingSend: false } this.streams.set(streamId, state) @@ -334,10 +346,21 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - stream.callbacks.onData(data, { - seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined, - rawLength: data.length - }) + const rawLength = data.length + // Why: a resync snapshot is authoritative; drop live output that arrives + // while it is in flight so the corrupt post-gap tail is never rendered. + if (stream.resyncInFlight) { + return + } + const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined + if (this.detectOutputGap(stream, seq, rawLength)) { + this.requestResyncSnapshot(stream) + return + } + if (typeof seq === 'number') { + stream.expectedSeq = seq + } + stream.callbacks.onData(data, { seq, rawLength }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { @@ -400,9 +423,17 @@ class RemoteRuntimeTerminalMultiplexer { clearPendingSnapshotRequest(stream) } clearSnapshot(stream) + // Why: the snapshot is the new authoritative output high-water; align the + // gap detector to it and re-open the live path (used by both the initial + // snapshot and a frame-drop resync, which reuses the 'initial' target). if (target === 'initial') { + stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.resyncInFlight = false + stream.resyncPendingSend = false stream.initialSnapshotReceived = true stream.callbacks.onSubscribed?.() + } else { + this.sendDeferredResyncSnapshot(stream) } return } @@ -412,12 +443,71 @@ class RemoteRuntimeTerminalMultiplexer { if (pendingSnapshotRequest) { clearPendingSnapshotRequest(stream) pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload))) + this.sendDeferredResyncSnapshot(stream) return } + // Why: a failed resync must re-open the live path or output stalls forever. + stream.resyncInFlight = false + stream.resyncPendingSend = false stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload)) } } + // Why: Output `seq` is the UTF-16 high-water at the end of a chunk, so a chunk + // that begins after the last high-water (startSeq > expectedSeq) means the + // server dropped intervening frames under backpressure. Only flag a gap when + // both offsets are known, and never on the first seq (nothing to compare to). + private detectOutputGap( + stream: RemoteRuntimeMultiplexedTerminalState, + seq: number | undefined, + rawLength: number + ): boolean { + if (typeof seq !== 'number' || typeof stream.expectedSeq !== 'number') { + return false + } + const startSeq = seq - rawLength + return startSeq > stream.expectedSeq + } + + // Why: on a detected gap, discard the corrupt tail and pull a fresh + // authoritative snapshot. The request carries no requestId so the server + // reply renders through the initial-snapshot path (full reset), self-healing + // without surfacing an error to the user. + private requestResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + if (stream.resyncInFlight) { + return + } + stream.resyncInFlight = true + stream.expectedSeq = undefined + if (stream.pendingSnapshotRequest) { + // Why: snapshot frame groups are not multiplexed; wait for the manual + // snapshot to finish so its response cannot be mistaken for recovery. + stream.resyncPendingSend = true + return + } + this.sendResyncSnapshot(stream) + } + + private sendDeferredResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + if (!stream.resyncInFlight || !stream.resyncPendingSend || stream.pendingSnapshotRequest) { + return + } + this.sendResyncSnapshot(stream) + } + + private sendResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + stream.resyncPendingSend = false + const sent = this.sendFrame( + stream.streamId, + TerminalStreamOpcode.SnapshotRequest, + encodeTerminalStreamJson({ scrollbackRows: undefined }) + ) + if (!sent) { + // Transport is down; the reconnect path re-subscribes from scratch. + stream.resyncInFlight = false + } + } + private requestSnapshot( stream: RemoteRuntimeMultiplexedTerminalState, opts?: { scrollbackRows?: number } @@ -431,6 +521,11 @@ class RemoteRuntimeTerminalMultiplexer { if (this.streams.get(stream.streamId) !== stream || !this.ready || !this.subscription) { return Promise.resolve(null) } + // Recovery uses an untagged snapshot frame group; callers can retry after + // it completes instead of racing another request onto the same frame lane. + if (stream.resyncInFlight) { + return Promise.resolve(null) + } if (stream.pendingSnapshotRequest) { return Promise.reject(new Error('Remote terminal snapshot already in flight.')) } @@ -438,8 +533,9 @@ class RemoteRuntimeTerminalMultiplexer { return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (stream.pendingSnapshotRequest?.timer === timer) { - stream.pendingSnapshotRequest = null + clearPendingSnapshotRequest(stream) reject(new Error('Remote terminal snapshot timed out.')) + this.sendDeferredResyncSnapshot(stream) } }, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS) if (typeof timer.unref === 'function') { diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index ecd9f1df790..15ec38e4005 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -29,6 +29,7 @@ import { type RemoteRuntimeSocketLivenessMonitor, type RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' +import { createWsOutboundBackpressureQueue } from './ws-outbound-backpressure-queue' export { RemoteRuntimeClientError } from './remote-runtime-client-error' @@ -375,6 +376,8 @@ export async function subscribeRemoteRuntimeRequest( const cleanupSocketListeners = (): WebSocket | null => { liveness?.stop() liveness = null + sendQueue?.dispose() + sendQueue = null const socket = ws if (!socket) { return null @@ -420,11 +423,37 @@ export async function subscribeRemoteRuntimeRequest( } } + // Why: client input (keystrokes) must never be dropped under backpressure. + // Hold encrypted frames in order while bufferedAmount is over the cap and + // drain as it clears; a wedged link (hard cap) fails the socket so the + // renderer resubscribes and replays a fresh snapshot. + let sendQueue: ReturnType> | null = null + const ensureSendQueue = ( + socket: WebSocket + ): ReturnType> => { + if (!sendQueue) { + sendQueue = createWsOutboundBackpressureQueue({ + send: (frame) => socket.send(frame, { binary: true }), + byteLengthOf: (frame) => frame.byteLength, + getBufferedAmount: () => socket.bufferedAmount, + isWritable: () => socket.readyState === WebSocket.OPEN, + onOverflow: () => + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime send buffer overflow; reconnecting.' + ) + ) + }) + } + return sendQueue + } + const sendBinary = (bytes: Uint8Array): boolean => { if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { return false } - ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true }) + ensureSendQueue(ws).enqueue(Buffer.from(encryptBytes(bytes, sharedKey))) return true } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 250ec27bf40..b355bda7dfa 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -346,18 +346,18 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) - it('refreshes pending request timeouts when keepalive frames show server progress', async () => { + it('times out a stuck short RPC on its absolute deadline despite keepalive frames', async () => { + // Why: a keepalive on the shared socket is armed by an unrelated long-poll, + // not by this request. It must NOT extend a stuck short RPC's deadline — + // otherwise a hung server call hangs the caller forever (#7948). const server = await createServer({ + silentMethods: ['worktree.hang'], sendKeepaliveBeforeResponse: true, - keepaliveDelayMs: 25, - responseDelayMs: 60 + keepaliveDelayMs: 20 }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - await expect(connection.request('worktree.ps', undefined, 50)).resolves.toMatchObject({ - ok: true, - result: { method: 'worktree.ps' } - }) + await expect(connection.request('worktree.hang', undefined, 60)).rejects.toThrow('Timed out') connection.close() }) @@ -618,6 +618,15 @@ function handleRequest( delayedResponses: (() => void)[] ): void { requests.push(request) + // Why: keepalives are armed by an unrelated long-poll and keep flowing even + // while a method is deliberately silent — emit them before the silent return. + if (options.sendKeepaliveBeforeResponse && options.keepaliveDelayMs !== undefined) { + const timer = setInterval( + () => sendEncrypted(ws, sharedKey, { _keepalive: true }), + options.keepaliveDelayMs + ) + ws.once('close', () => clearInterval(timer)) + } if (options.silentMethods?.includes(request.method)) { return } @@ -647,13 +656,10 @@ function handleRequest( }) } const closeAfterResponse = streaming && options.closeAfterStreamingResponse?.() === true - if (options.sendKeepaliveBeforeResponse) { - const sendKeepalive = (): void => sendEncrypted(ws, sharedKey, { _keepalive: true }) - if (options.keepaliveDelayMs !== undefined) { - setTimeout(sendKeepalive, options.keepaliveDelayMs) - } else { - sendKeepalive() - } + // Delayed/periodic keepalives are handled by the interval above; here we only + // cover the immediate single-keepalive-before-response case. + if (options.sendKeepaliveBeforeResponse && options.keepaliveDelayMs === undefined) { + sendEncrypted(ws, sharedKey, { _keepalive: true }) } if (options.delaySubscriptionReady && streaming) { delayedResponses.push(sendResponse) diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts new file mode 100644 index 00000000000..6c1ec5c7db5 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { requestSharedControl } from './remote-runtime-shared-control-requests' +import { + refreshSharedControlPendingRequestTimeouts, + resolveSharedControlPendingResponse +} from './remote-runtime-shared-control-state' +import type { SharedControlPendingRequest } from './remote-runtime-shared-control-types' + +// Why: a keepalive frame on the shared-control socket is armed by an unrelated +// long-poll, not by any given pending short RPC. These fake-timer tests pin the +// deadline semantics: keepalives must NOT keep a stuck short RPC alive forever, +// but MAY extend a long-poll that opted into the short-RPC path. +describe('shared control keepalive timeout refresh semantics', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function startRequest(options: { refreshTimeoutOnKeepalive?: boolean } = {}): { + pendingRequests: Map> + promise: Promise + onTimeout: ReturnType + } { + const pendingRequests = new Map>() + const onTimeout = vi.fn() + const promise = requestSharedControl({ + pendingRequests, + method: 'git.status', + params: undefined, + timeoutMs: 1000, + // ensureReady resolves immediately; the request is "in flight" but the + // server never answers, modelling a genuinely stuck server-side call. + ensureReady: () => Promise.resolve(), + send: () => undefined, + onTimeout, + refreshTimeoutOnKeepalive: options.refreshTimeoutOnKeepalive + }) + // Swallow the eventual rejection so unhandled-rejection noise doesn't leak. + promise.catch(() => undefined) + return { pendingRequests, promise, onTimeout } + } + + it('times out a stuck short RPC even while keepalive frames keep arriving', async () => { + const { pendingRequests, promise, onTimeout } = startRequest() + + // Periodic keepalives arrive faster than the 1000ms deadline — as they + // would while a long-poll subscription streams over the same socket. + for (let elapsed = 0; elapsed < 1000; elapsed += 200) { + await vi.advanceTimersByTimeAsync(200) + refreshSharedControlPendingRequestTimeouts(pendingRequests) + } + await vi.advanceTimersByTimeAsync(1) + + await expect(promise).rejects.toThrow() + // The stuck-request path tears the connection down so reconnect+replay runs. + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(pendingRequests.size).toBe(0) + }) + + it('keeps refreshing a long-poll request that opted into keepalive refresh', async () => { + const { pendingRequests, promise, onTimeout } = startRequest({ + refreshTimeoutOnKeepalive: true + }) + + // Same keepalive cadence, but this request opted in, so each keepalive + // pushes the deadline out and it never fires. + for (let elapsed = 0; elapsed < 3000; elapsed += 200) { + await vi.advanceTimersByTimeAsync(200) + refreshSharedControlPendingRequestTimeouts(pendingRequests) + } + + expect(onTimeout).not.toHaveBeenCalled() + expect(pendingRequests.size).toBe(1) + + // It still resolves normally once the server finally answers. + const [requestId] = pendingRequests.keys() + resolveSharedControlPendingResponse(pendingRequests, requestId!, { + id: requestId!, + ok: true, + result: { done: true }, + _meta: { runtimeId: 'runtime-test' } + }) + await expect(promise).resolves.toMatchObject({ ok: true }) + }) + + it('fires the deadline for a short RPC when no keepalives arrive', async () => { + const { pendingRequests, promise, onTimeout } = startRequest() + + await vi.advanceTimersByTimeAsync(1001) + + await expect(promise).rejects.toThrow() + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(pendingRequests.size).toBe(0) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 5f8d0ca2b7c..91f555af787 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -17,6 +17,9 @@ export function requestSharedControl(args: { ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void onTimeout?: (error: RemoteRuntimeClientError) => void + // Why: default off — ordinary short RPCs keep an absolute deadline. Only + // long-polls routed through this path opt in so keepalives extend them. + refreshTimeoutOnKeepalive?: boolean }): Promise> { const requestId = randomUUID() return new Promise>((resolve, reject) => { @@ -41,7 +44,8 @@ export function requestSharedControl(args: { method: args.method, resolve: resolve as (response: RuntimeRpcResponse) => void, reject, - timeout + timeout, + refreshTimeoutOnKeepalive: args.refreshTimeoutOnKeepalive ?? false }) void args.ensureReady().then( () => args.send(requestId, args.method, args.params), diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 72f139a4945..0190398e3c8 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -64,6 +64,11 @@ export function refreshSharedControlPendingRequestTimeouts( pendingRequests: Map> ): void { for (const pending of pendingRequests.values()) { + // Why: only long-poll requests opted into keepalive refresh; refreshing an + // ordinary short RPC would keep a genuinely-stuck server call alive forever. + if (!pending.refreshTimeoutOnKeepalive) { + continue + } const timeout = pending.timeout as ReturnType & { refresh?: () => void } timeout.refresh?.() } @@ -144,6 +149,13 @@ export function handleSharedControlSubscriptionResponse( subscription: SharedControlLogicalSubscription, response: RuntimeRpcResponse ): void { + // The replay window ends on either success or error. An error created no + // remote subscription, so a later close must finish locally instead of + // waiting forever for an id that will never arrive. + subscription.awaitingResubscribe = false + if (!response.ok) { + subscription.sent = false + } if (response.ok) { const subscriptionId = getSubscriptionId(response.result) if (subscriptionId) { diff --git a/src/shared/remote-runtime-shared-control-subscriptions.test.ts b/src/shared/remote-runtime-shared-control-subscriptions.test.ts new file mode 100644 index 00000000000..a1b843f7488 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscriptions.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import { + closeSharedControlLogicalSubscription, + createSharedControlSubscription, + handleSharedControlLogicalResponse, + replaySharedControlSubscriptions +} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +function makeSubscriptions(): { + subscriptions: Map> + subscription: SharedControlLogicalSubscription +} { + const subscriptions = new Map>() + const subscription = createSharedControlSubscription({ + requestId: 'req-1', + method: 'runtime.clientEvents.subscribe', + params: null, + callbacks: { onResponse: vi.fn(), onError: vi.fn() } + }) + subscriptions.set(subscription.requestId, subscription) + return { subscriptions, subscription } +} + +function okResponse(subscriptionId: string): RuntimeRpcResponse { + return { + ok: true, + id: 'req-1', + result: { subscriptionId } + } as unknown as RuntimeRpcResponse +} + +describe('closeSharedControlLogicalSubscription — replay-window leak', () => { + it('sends the unsubscribe when closed after an established subscribe replay completes', () => { + const { subscriptions, subscription } = makeSubscriptions() + // First establishment: server assigned a concrete subscription id. + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + // Established subscription cleans up immediately by its known id. + expect(request).toHaveBeenCalledWith('runtime.clientEvents.unsubscribe', { + subscriptionId: 'server-sub-1' + }) + expect(subscriptions.size).toBe(0) + }) + + it('does NOT leak a server subscription when close races the replay resubscribe window', () => { + const { subscriptions, subscription } = makeSubscriptions() + // Subscription was previously established on the server. + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + + // Reconnect replay: sent flips false and the old id is cleared right before + // the resubscribe frame goes out. The server WILL assign a fresh id. + const sent: SharedControlLogicalSubscription[] = [] + replaySharedControlSubscriptions({ + subscriptions, + send: (s) => { + sent.push(s) + }, + tagReplayedResponses: true + }) + expect(subscription.sent).toBe(false) + expect(subscription.remoteSubscriptionId).toBeNull() + + // close() arrives during the window: no remoteSubscriptionId yet, so a + // naive close finishes locally and never unsubscribes — leaking the server + // subscription that the in-flight resubscribe is about to create. + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + // The close must be deferred, not finished with no cleanup. + expect(subscriptions.size).toBe(1) + expect(request).not.toHaveBeenCalled() + + // When the resubscribe's ready response arrives with the new server id, the + // deferred close fires the unsubscribe and only then finishes. + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: okResponse('server-sub-2'), + request + }) + + expect(request).toHaveBeenCalledWith('runtime.clientEvents.unsubscribe', { + subscriptionId: 'server-sub-2' + }) + expect(subscriptions.size).toBe(0) + }) + + it('finishes locally without an unsubscribe for a never-sent subscription', () => { + const { subscriptions, subscription } = makeSubscriptions() + // Brand new: never sent, no server subscription exists. + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) + + it('finishes a deferred close locally when replay returns an error', () => { + const { subscriptions, subscription } = makeSubscriptions() + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + replaySharedControlSubscriptions({ subscriptions, send: vi.fn(), tagReplayedResponses: true }) + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: { + ok: false, + id: 'req-1', + error: { code: 'replay_failed', message: 'replay failed' } + } as RuntimeRpcResponse, + request + }) + + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) + + it('does not leave a later close waiting after replay already failed', () => { + const { subscriptions, subscription } = makeSubscriptions() + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + replaySharedControlSubscriptions({ subscriptions, send: vi.fn(), tagReplayedResponses: true }) + + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: { + ok: false, + id: 'req-1', + error: { code: 'replay_failed', message: 'replay failed' } + } as RuntimeRpcResponse, + request: vi.fn() + }) + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index d4122eac9e4..3385cfc390a 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -62,9 +62,14 @@ export function closeSharedControlLogicalSubscription(args: { args.request(cleanup.method, cleanup.params) return } - if (args.subscription.sent && cleanupNeedsRemoteSubscriptionId(args.subscription.method)) { + if ( + (args.subscription.sent || args.subscription.awaitingResubscribe) && + cleanupNeedsRemoteSubscriptionId(args.subscription.method) + ) { // Why: id-scoped server subscriptions can only be cleaned up after the - // server returns its concrete subscription id in the ready response. + // server returns its concrete subscription id in the ready response. This + // also covers the reconnect replay window (sent===false, id cleared) where + // a resubscribe is in flight — finishing locally there would leak it. args.subscription.closeAfterReady = true return } @@ -100,6 +105,10 @@ export function replaySharedControlSubscriptions(args: { } subscription.sent = false subscription.remoteSubscriptionId = null + // Why: mark the id-less window so a close() racing this resubscribe defers + // to closeAfterReady instead of finishing locally and leaking the server + // subscription the resubscribe is about to create. + subscription.awaitingResubscribe = true if (args.tagReplayedResponses) { subscription.pendingReplayTag = true } diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 4eb671ea3b3..98babc9d5a1 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -12,6 +12,12 @@ export type SharedControlPendingRequest = { resolve: (response: RuntimeRpcResponse) => void reject: (error: Error) => void timeout: ReturnType + // Why: keepalives on the shared socket are armed for an unrelated long-poll, + // not this request. Only requests that opt in (long-polls issued via the + // short-RPC path) may have their deadline refreshed by a keepalive; ordinary + // short RPCs keep an absolute deadline so a stuck server call still times + // out, tears the socket down, and reconnects/replays as designed. + refreshTimeoutOnKeepalive: boolean } export type SharedControlSubscriptionCallbacks = { @@ -34,6 +40,11 @@ export type SharedControlLogicalSubscription = { // response is the authoritative re-emitted snapshot and gets tagged so // monotonic freshness gates don't drop it (#7718). pendingReplayTag?: boolean + // Why: true from the moment a reconnect replay clears remoteSubscriptionId + // until the resubscribe response arrives. A close() during this window has no + // id to unsubscribe by yet, so it must defer instead of finishing locally — + // otherwise the resubscribe the server is about to accept leaks. + awaitingResubscribe?: boolean } export type SharedControlReadyWaiter = { diff --git a/src/shared/ws-outbound-backpressure-queue.test.ts b/src/shared/ws-outbound-backpressure-queue.test.ts new file mode 100644 index 00000000000..6e8dd66479d --- /dev/null +++ b/src/shared/ws-outbound-backpressure-queue.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWsOutboundBackpressureQueue } from './ws-outbound-backpressure-queue' + +// Deterministic harness: bufferedAmount and the drain timer are both injected, +// so no wall-clock races. `runTimers` fires the single parked drain callback. + +function createHarness(overrides?: { + softCapBytes?: number + maxQueuedBytes?: number + writable?: boolean +}) { + const sent: string[] = [] + let bufferedAmount = 0 + let writable = overrides?.writable ?? true + const overflow = vi.fn() + let pendingTimer: (() => void) | null = null + + const queue = createWsOutboundBackpressureQueue({ + send: (frame) => sent.push(frame), + byteLengthOf: (frame) => frame.length, + getBufferedAmount: () => bufferedAmount, + isWritable: () => writable, + onOverflow: overflow, + softCapBytes: overrides?.softCapBytes ?? 100, + maxQueuedBytes: overrides?.maxQueuedBytes ?? 1000, + drainPollMs: 10, + setTimer: (cb) => { + pendingTimer = cb + return 1 as unknown as ReturnType + }, + clearTimer: () => { + pendingTimer = null + } + }) + + return { + queue, + sent, + overflow, + setBuffered: (value: number) => { + bufferedAmount = value + }, + setWritable: (value: boolean) => { + writable = value + }, + runTimer: () => { + const cb = pendingTimer + pendingTimer = null + cb?.() + }, + hasTimer: () => pendingTimer !== null + } +} + +describe('ws outbound backpressure queue', () => { + it('sends straight through while under the soft cap', () => { + const h = createHarness() + h.queue.enqueue('a') + h.queue.enqueue('b') + expect(h.sent).toEqual(['a', 'b']) + expect(h.hasTimer()).toBe(false) + }) + + it('parks frames in order while over the cap and drains on recovery without loss', () => { + const h = createHarness({ softCapBytes: 100 }) + h.setBuffered(200) // over cap + h.queue.enqueue('one') + h.queue.enqueue('two') + h.queue.enqueue('three') + // Nothing sent yet; all held in order. + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe('one'.length + 'two'.length + 'three'.length) + + // Link recovers; the drain timer flushes everything in FIFO order. + h.setBuffered(0) + h.runTimer() + expect(h.sent).toEqual(['one', 'two', 'three']) + expect(h.queue.queuedBytes()).toBe(0) + }) + + it('keeps ordering when a frame arrives while a backlog is parked', () => { + const h = createHarness({ softCapBytes: 100 }) + h.setBuffered(200) + h.queue.enqueue('first') + h.setBuffered(0) + // Even though the wire is now clear, an existing backlog means the new + // frame must queue behind it, not jump the line. + h.queue.enqueue('second') + expect(h.sent).toEqual([]) + h.runTimer() + expect(h.sent).toEqual(['first', 'second']) + }) + + it('signals overflow (and drops backlog) when the hard cap is exceeded', () => { + const h = createHarness({ softCapBytes: 10, maxQueuedBytes: 8 }) + h.setBuffered(100) // over soft cap: everything queues + h.queue.enqueue('12345') // 5 bytes queued + expect(h.overflow).not.toHaveBeenCalled() + h.queue.enqueue('67890') // 10 bytes total > 8 -> overflow + expect(h.overflow).toHaveBeenCalledTimes(1) + // After overflow the queue is inert: no sends, no further overflow calls. + h.setBuffered(0) + h.runTimer() + h.queue.enqueue('later') + expect(h.sent).toEqual([]) + expect(h.overflow).toHaveBeenCalledTimes(1) + }) + + it('drops the backlog if the socket becomes unwritable mid-park', () => { + const h = createHarness({ softCapBytes: 10 }) + h.setBuffered(100) + h.queue.enqueue('data') + h.setWritable(false) + h.runTimer() + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe(0) + }) + + it('does not fast-path a frame while the socket is unwritable', () => { + const h = createHarness({ writable: false }) + + h.queue.enqueue('data') + + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe('data'.length) + expect(h.hasTimer()).toBe(true) + }) +}) diff --git a/src/shared/ws-outbound-backpressure-queue.ts b/src/shared/ws-outbound-backpressure-queue.ts new file mode 100644 index 00000000000..666fcb0decf --- /dev/null +++ b/src/shared/ws-outbound-backpressure-queue.ts @@ -0,0 +1,147 @@ +// Why: both the server reply path (e2ee-channel) and the client send path +// (remote-runtime-client) write to a ws with no backpressure handling. A fast +// producer over a slow link balloons ws.bufferedAmount / RSS without bound, or +// (binary path) silently drops frames. This queue holds outbound frames in +// order while bufferedAmount is over a soft cap and flushes as it drains, so no +// frame is dropped or reordered. It only signals overflow when a hard byte +// bound is exceeded (the link is effectively dead), letting the caller force a +// clean reconnect/resync instead of growing memory without limit. +// +// Generic over the frame type so it serves both the text reply path (encrypted +// base64 strings) and the binary send path (Uint8Array frames). + +export type WsOutboundBackpressureQueueOptions = { + /** Send a frame on the wire. Called only when under the soft cap. */ + send: (frame: TFrame) => void + /** Serialized byte length of a frame, for cap accounting. */ + byteLengthOf: (frame: TFrame) => number + /** Current ws.bufferedAmount in bytes. */ + getBufferedAmount: () => number + /** True when the socket can still accept sends (OPEN and keyed). */ + isWritable: () => boolean + /** + * Called once when queued bytes exceed maxQueuedBytes — the link is wedged. + * The caller should tear the connection down so a fresh subscription can + * replay an authoritative snapshot. The queue drops its backlog afterward. + */ + onOverflow: () => void + /** Soft cap: stop draining onto the wire while bufferedAmount is above this. */ + softCapBytes?: number + /** Hard cap on bytes held in this queue before onOverflow fires. */ + maxQueuedBytes?: number + /** Poll interval used to re-check bufferedAmount while parked. */ + drainPollMs?: number + /** Injectable scheduler for deterministic tests. */ + setTimer?: (cb: () => void, ms: number) => ReturnType + clearTimer?: (timer: ReturnType) => void +} + +export type WsOutboundBackpressureQueue = { + /** Queue-or-send a frame. Preserves order across all prior frames. */ + enqueue: (frame: TFrame) => void + /** Bytes currently held (not yet handed to the wire). */ + queuedBytes: () => number + /** Drop the backlog and stop the drain timer (call on close). */ + dispose: () => void +} + +const DEFAULT_SOFT_CAP_BYTES = 8 * 1024 * 1024 +// Why: tolerate a large transient burst (e.g. a build log spike) before +// declaring the link dead; 64 MiB is ~8x the soft cap yet still bounds RSS. +const DEFAULT_MAX_QUEUED_BYTES = 64 * 1024 * 1024 +const DEFAULT_DRAIN_POLL_MS = 25 + +export function createWsOutboundBackpressureQueue( + options: WsOutboundBackpressureQueueOptions +): WsOutboundBackpressureQueue { + const softCapBytes = options.softCapBytes ?? DEFAULT_SOFT_CAP_BYTES + const maxQueuedBytes = options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES + const drainPollMs = options.drainPollMs ?? DEFAULT_DRAIN_POLL_MS + const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms)) + const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer)) + + // Why: a ws without a numeric bufferedAmount (some mocks/transports) must not + // strand frames in the queue forever; treat unknown backpressure as "clear". + const bufferedAmount = (): number => { + const value = options.getBufferedAmount() + return Number.isFinite(value) ? value : 0 + } + + const queue: { frame: TFrame; bytes: number }[] = [] + let queueHead = 0 + let queued = 0 + let timer: ReturnType | null = null + let overflowed = false + let disposed = false + + const stopTimer = (): void => { + if (timer !== null) { + clearTimer(timer) + timer = null + } + } + + const dropBacklog = (): void => { + queue.length = 0 + queueHead = 0 + queued = 0 + stopTimer() + } + + // Drain as many queued frames as the wire will take without crossing the + // soft cap; re-arm the poll timer if frames remain. + const drain = (): void => { + if (disposed || overflowed) { + return + } + if (!options.isWritable()) { + // Socket went away mid-park; let the transport's own close path clean up. + dropBacklog() + return + } + while (queueHead < queue.length && bufferedAmount() <= softCapBytes) { + const entry = queue[queueHead++] + queued -= entry.bytes + options.send(entry.frame) + } + if (queueHead < queue.length) { + timer = setTimer(drain, drainPollMs) + } else { + // Why: resetting the drained array keeps enqueue/drain O(1) per frame; + // repeated Array.shift() would make recovery from a large backlog O(n²). + queue.length = 0 + queueHead = 0 + stopTimer() + } + } + + return { + enqueue(frame: TFrame): void { + if (disposed || overflowed) { + return + } + // Fast path: nothing parked and the wire is under the cap — send directly. + if (queueHead === queue.length && options.isWritable() && bufferedAmount() <= softCapBytes) { + options.send(frame) + return + } + const bytes = options.byteLengthOf(frame) + queue.push({ frame, bytes }) + queued += bytes + if (queued > maxQueuedBytes) { + overflowed = true + dropBacklog() + options.onOverflow() + return + } + if (timer === null) { + timer = setTimer(drain, drainPollMs) + } + }, + queuedBytes: () => queued, + dispose(): void { + disposed = true + dropBacklog() + } + } +} From 143c36e566483006fe54fa2716b019ec00599752 Mon Sep 17 00:00:00 2001 From: Andres Van Reepingen Date: Fri, 10 Jul 2026 21:57:09 +0200 Subject: [PATCH 03/95] feat(jira): group Jira issues by status (#7958) * fix(jira): group Jira issues by status in task page Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(jira): order grouped statuses by agile board columns configuration * test(jira): add comprehensive tests for Jira issue grouping functionality Add test coverage for the new Jira issue grouping features including: - Grouping issues by status name - Sorting sections by agile board column configuration - Falling back to alphabetical sorting when no board config exists - Collapsed groups state management - Filtering issues based on collapsed state - Backend tests for getProjectStatuses API with various scenarios Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(jira): add backend tests for getProjectStatuses and RPC routing Add test coverage for the new getProjectStatuses backend functionality: - Returns empty array when no clients are available - Returns statuses from project statuses API when no board configuration exists - Orders statuses by agile board column configuration when available - Handles missing status IDs gracefully by falling back to unordered list - Clears token on auth errors - Returns empty array on operational errors - RPC routing for jira.getProjectStatuses method Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jira): harden status grouping --------- Co-authored-by: Andres Van Reepingen Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/ipc/jira.ts | 11 + src/main/jira/issues.test.ts | 96 +++++ src/main/jira/issues.ts | 82 ++++ src/main/runtime/orca-runtime.ts | 8 + src/main/runtime/rpc/methods/jira.test.ts | 9 +- src/main/runtime/rpc/methods/jira.ts | 11 + src/preload/api-types.ts | 5 + src/preload/index.ts | 7 +- src/renderer/src/components/TaskPage.tsx | 237 +++--------- .../task-page-jira-grouping.test.ts | 171 +++++++++ .../components/task-page-jira-issue-list.tsx | 349 ++++++++++++++++++ .../components/task-page-jira-status-order.ts | 63 ++++ .../src/runtime/runtime-jira-client.ts | 15 + src/shared/jira-types.ts | 4 + src/shared/types.ts | 1 + 15 files changed, 887 insertions(+), 182 deletions(-) create mode 100644 src/renderer/src/components/task-page-jira-grouping.test.ts create mode 100644 src/renderer/src/components/task-page-jira-issue-list.tsx create mode 100644 src/renderer/src/components/task-page-jira-status-order.ts diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e8ed17f9bfe..e4a0e7ff895 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -6,6 +6,7 @@ import { createIssue, getIssue, getIssueComments, + getProjectStatusOrder, listAssignableUsers, listCreateFields, listIssueTypes, @@ -259,4 +260,14 @@ export function registerJiraHandlers(): void { } return listTransitions(args.key.trim(), normalizeSiteId(args.siteId)) }) + + ipcMain.handle( + 'jira:getProjectStatusOrder', + async (_event, args: { projectKey: string; siteId?: string }) => { + if (typeof args?.projectKey !== 'string' || !args.projectKey.trim()) { + return { statusIdsByColumn: [] } + } + return getProjectStatusOrder(args.projectKey.trim(), normalizeSiteId(args.siteId)) + } + ) } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 49a49a46076..1cf9ab31212 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -398,4 +398,100 @@ describe('Jira issue operations', () => { } ]) }) + + describe('getProjectStatusOrder', () => { + it('returns an empty order when no clients are available', async () => { + getClientsMock.mockReturnValue([]) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + }) + + it('returns an empty order when an omitted site resolves to multiple clients', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP')).resolves.toEqual({ statusIdsByColumn: [] }) + expect(jiraRequestMock).not.toHaveBeenCalled() + }) + + it('returns an empty order when the project has no accessible board', async () => { + jiraRequestMock.mockResolvedValueOnce({ values: [] }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP & OPS', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(String(jiraRequestMock.mock.calls[0][1])).toContain( + '/rest/agile/1.0/board?projectKeyOrId=ALP+%26+OPS&maxResults=2' + ) + }) + + it('keeps alphabetical fallback when a project has multiple boards', async () => { + jiraRequestMock.mockResolvedValueOnce({ + total: 2, + values: [{ id: 42 }, { id: 43 }] + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + }) + + it('keeps alphabetical fallback when Jira reports another board page', async () => { + jiraRequestMock.mockResolvedValueOnce({ + isLast: false, + values: [{ id: 42 }] + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + }) + + it('returns status IDs grouped by Jira board column order', async () => { + jiraRequestMock + .mockResolvedValueOnce({ total: 1, values: [{ id: 42 }] }) + .mockResolvedValueOnce({ + columnConfig: { + columns: [ + { statuses: [{ id: '1' }, { id: '2' }] }, + { statuses: [{ id: '3' }, { id: '2' }] }, + { statuses: [] } + ] + } + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [['1', '2'], ['3']] + }) + expect(jiraRequestMock.mock.calls[1]?.[1]).toBe('/rest/agile/1.0/board/42/configuration') + }) + + it('clears the token and surfaces credential failures', async () => { + const authError = new Error('Unauthorized') + isAuthErrorMock.mockReturnValue(true) + jiraRequestMock.mockRejectedValueOnce(authError) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).rejects.toThrow('Unauthorized') + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + + it('falls back to an empty order on operational errors', async () => { + jiraRequestMock.mockRejectedValueOnce(new Error('Service Unavailable')) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + }) + }) }) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 3bf8237349c..5a19b9897b3 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -14,6 +14,7 @@ import type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSite, JiraSiteSelection, JiraStatus, @@ -110,6 +111,13 @@ function asString(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback } +function asIdentifier(value: unknown): string { + if (typeof value === 'string') { + return value + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') @@ -810,3 +818,77 @@ export async function listTransitions( release() } } + +export async function getProjectStatusOrder( + projectKey: string, + siteId?: string | null +): Promise { + // Why: an omitted site can resolve to the persisted "all" selection; board + // metadata is only truthful when exactly one Jira connection owns the project. + const entries = getClients(siteId) + const entry = entries.length === 1 ? entries[0] : undefined + if (!entry) { + return { statusIdsByColumn: [] } + } + await acquire() + try { + // Why: without an explicit board picker there is no truthful way to choose + // among multiple project boards, so ambiguous projects keep alphabetical order. + const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) + const boardsResponse = await jiraRequest>( + entry, + `/rest/agile/1.0/board?${params.toString()}` + ) + const boards = boardsResponse.values ?? [] + const boardCount = asFiniteNumber(boardsResponse.total) + const singleBoardIsProven = + boards.length === 1 && + boardsResponse.isLast !== false && + (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) + const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' + if (!boardId) { + return { statusIdsByColumn: [] } + } + + const configResponse = await jiraRequest( + entry, + `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` + ) + const columns = asRecord(asRecord(configResponse).columnConfig).columns + if (!Array.isArray(columns)) { + return { statusIdsByColumn: [] } + } + + // Why: issues already carry status names and IDs, so returning board IDs + // avoids a second metadata request and keeps duplicate names unambiguous. + const seenStatusIds = new Set() + const statusIdsByColumn: string[][] = [] + for (const column of columns) { + const statuses = asRecord(column).statuses + if (!Array.isArray(statuses)) { + continue + } + const columnStatusIds: string[] = [] + for (const status of statuses) { + const statusId = asIdentifier(asRecord(status).id) + if (statusId && !seenStatusIds.has(statusId)) { + seenStatusIds.add(statusId) + columnStatusIds.push(statusId) + } + } + if (columnStatusIds.length > 0) { + statusIdsByColumn.push(columnStatusIds) + } + } + return { statusIdsByColumn } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getProjectStatusOrder failed:', error) + return { statusIdsByColumn: [] } + } finally { + release() + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9625b5e0cb7..825caa0dc72 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -534,6 +534,7 @@ import { createIssue as createJiraIssue, getIssue as getJiraIssue, getIssueComments as getJiraIssueComments, + getProjectStatusOrder as getJiraProjectStatusOrder, listAssignableUsers as listJiraAssignableUsers, listCreateFields as listJiraCreateFields, listIssueTypes as listJiraIssueTypes, @@ -22751,6 +22752,13 @@ export class OrcaRuntimeService { return listJiraTransitions(key, siteId) } + jiraGetProjectStatusOrder( + projectKey: string, + siteId?: string + ): ReturnType { + return getJiraProjectStatusOrder(projectKey, siteId) + } + // ── Browser automation ── private readonly browserCommands = new RuntimeBrowserCommands({ diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 8df96326c0a..8bf82eab593 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -125,7 +125,10 @@ describe('jira RPC methods', () => { jiraListCreateFields: vi.fn().mockResolvedValue([{ key: 'customfield_10010' }]), jiraListPriorities: vi.fn().mockResolvedValue([{ id: 'priority-1' }]), jiraListAssignableUsers: vi.fn().mockResolvedValue([{ accountId: 'user-1' }]), - jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]) + jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]), + jiraGetProjectStatusOrder: vi.fn().mockResolvedValue({ + statusIdsByColumn: [['status-1']] + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) @@ -151,6 +154,9 @@ describe('jira RPC methods', () => { await dispatcher.dispatch( makeRequest('jira.listTransitions', { key: 'ABC-3', siteId: 'site-1' }) ) + await dispatcher.dispatch( + makeRequest('jira.getProjectStatusOrder', { projectKey: 'ALP', siteId: 'site-1' }) + ) expect(runtime.jiraListProjects).toHaveBeenCalledWith('all') expect(runtime.jiraListIssueTypes).toHaveBeenCalledWith('project-1', 'site-1') @@ -158,5 +164,6 @@ describe('jira RPC methods', () => { expect(runtime.jiraListPriorities).toHaveBeenCalledWith('site-1') expect(runtime.jiraListAssignableUsers).toHaveBeenCalledWith('ABC-3', 'Ada', 'site-1') expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1') + expect(runtime.jiraGetProjectStatusOrder).toHaveBeenCalledWith('ALP', 'site-1') }) }) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 9a4c614d9ee..35d1d067ca1 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -88,6 +88,11 @@ const AssignableUsers = z.object({ siteId: OptionalString }) +const ProjectStatusOrder = z.object({ + projectKey: requiredString('Project key is required'), + siteId: OptionalString +}) + export const JIRA_METHODS: RpcMethod[] = [ defineMethod({ name: 'jira.connect', @@ -204,5 +209,11 @@ export const JIRA_METHODS: RpcMethod[] = [ params: IssueKey, handler: async (params, { runtime }) => runtime.jiraListTransitions(params.key.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.getProjectStatusOrder', + params: ProjectStatusOrder, + handler: async (params, { runtime }) => + runtime.jiraGetProjectStatusOrder(params.projectKey.trim(), params.siteId) }) ] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7604538ab74..100b89b97f4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -121,6 +121,7 @@ import type { JiraIssue, JiraIssueFilter, JiraIssueType, + JiraProjectStatusOrder, JiraIssueUpdate, JiraPriority, JiraProject, @@ -1931,6 +1932,10 @@ export type PreloadApi = { siteId?: string }) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }) => Promise } starNag: { onShow: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 8bbe17f7cc7..9f63a71d985 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,7 @@ import type { GitHubAssignableUser, GitHubCommentResult, GitHubWorkItem, + JiraProjectStatusOrder, GitPushTarget, GitStagingArea, GitForkSyncExpectedUpstream, @@ -1665,7 +1666,11 @@ const api = { }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args) + ipcRenderer.invoke('jira:listTransitions', args), + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) }, starNag: { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 810063e3929..5e74b874b55 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -159,6 +159,12 @@ import { LinearProjectTable } from '@/components/linear-project-view-surfaces' import JiraIssueWorkspace from '@/components/JiraIssueWorkspace' +import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list' +import { + getSingleJiraProjectScope, + getTaskPageJiraStatusOrderScopeKey, + loadTaskPageJiraProjectStatusOrder +} from '@/components/task-page-jira-status-order' import { JiraIcon } from '@/components/icons/JiraIcon' import { cn } from '@/lib/utils' import { @@ -277,6 +283,7 @@ import type { JiraIssue, JiraIssueType, JiraProject, + JiraProjectStatusOrder, LinearIssue, LinearProjectDetail, LinearProjectSummary, @@ -3501,6 +3508,9 @@ export default function TaskPage(): React.JSX.Element { selectedJiraSiteId ] ) + const jiraTaskSourceScopeKey = jiraTaskSourceContext + ? getTaskSourceCacheScope(jiraTaskSourceContext) + : providerRuntimeContextKey const accountBackedTaskSourceHostAvailability = useMemo(() => { if (taskSource !== 'linear' && taskSource !== 'jira') { return [] @@ -4575,6 +4585,10 @@ export default function TaskPage(): React.JSX.Element { const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('assigned') const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0) + const [jiraProjectStatusOrder, setJiraProjectStatusOrder] = useState<{ + order: JiraProjectStatusOrder + scopeKey: string + } | null>(null) useEffect(() => { if (taskResumeAppliedRef.current || !persistedUIReady || !settings) { @@ -5595,6 +5609,17 @@ export default function TaskPage(): React.JSX.Element { ), [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, jiraTaskSourceContext] ) + const displayedJiraProjectScope = useMemo( + () => getSingleJiraProjectScope(displayedJiraIssues), + [displayedJiraIssues] + ) + const displayedJiraStatusOrderScopeKey = displayedJiraProjectScope + ? getTaskPageJiraStatusOrderScopeKey(jiraTaskSourceScopeKey, displayedJiraProjectScope) + : null + const displayedJiraStatusOrder = + jiraProjectStatusOrder && displayedJiraStatusOrderScopeKey === jiraProjectStatusOrder.scopeKey + ? jiraProjectStatusOrder.order + : null // New Linear project dialog state const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false) @@ -7792,6 +7817,26 @@ export default function TaskPage(): React.JSX.Element { } setJiraIssues(issues) setJiraLoading(false) + const projectScope = getSingleJiraProjectScope(issues) + if (!projectScope) { + return + } + const statusOrderScopeKey = getTaskPageJiraStatusOrderScopeKey( + jiraTaskSourceScopeKey, + projectScope + ) + void loadTaskPageJiraProjectStatusOrder( + jiraTaskSourceContext ?? settings, + jiraTaskSourceScopeKey, + projectScope + ).then((order) => { + if (!cancelled) { + setJiraProjectStatusOrder({ + order, + scopeKey: statusOrderScopeKey + }) + } + }) }) .catch((err) => { if (cancelled) { @@ -7815,7 +7860,8 @@ export default function TaskPage(): React.JSX.Element { activeJiraPreset, jiraRefreshNonce, taskResumeApplied, - jiraTaskSourceContext + jiraTaskSourceContext, + jiraTaskSourceScopeKey ]) useEffect(() => { @@ -10026,185 +10072,16 @@ export default function TaskPage(): React.JSX.Element { ) : null} -
- {displayedJiraIssues.map((issue) => { - const selected = issue.key === selectedJiraIssueKey - const labels = issue.labels.slice(0, 3) - const contextLabel = - selectedJiraSiteId === 'all' && issue.siteName - ? `${issue.siteName} / ${issue.project.key}` - : issue.project.key - return ( -
openJiraDetailPage(issue)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) { - return - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - openJiraDetailPage(issue) - } - }} - className={cn( - 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]', - selected && 'bg-accent' - )} - > - - {issue.key} - - -
-
- - {issue.key} - -

- {issue.title} -

-
-
- - {issue.status.name} - - - {issue.priority?.name ?? - translate('auto.components.TaskPage.713179dfdc', 'No priority')} - - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
-
- - {contextLabel} - - {labels.map((label) => ( - - {label} - - ))} - {issue.labels.length > labels.length ? ( - - +{issue.labels.length - labels.length} - - ) : null} -
-
- -
- - {issue.status.name} - -
- - - {issue.priority?.name ?? - translate('auto.components.TaskPage.713179dfdc', 'No priority')} - - -
- {issue.assignee?.avatarUrl ? ( - {issue.assignee.displayName} - ) : ( - - {issue.assignee?.displayName?.slice(0, 1) ?? '-'} - - )} - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
- - - -
- {formatRelativeTime(issue.updatedAt)} -
-
- - {new Date(issue.updatedAt).toLocaleString()} - -
- -
- - - - - - {translate( - 'auto.components.TaskPage.9497f2787c', - 'Start workspace' - )} - - - - - - - - {translate('auto.components.TaskPage.eee68073b2', 'Open in Jira')} - - -
-
- ) - })} -
+ ({ + jiraGetProjectStatusOrderMock: vi.fn() +})) + +vi.mock('@/runtime/runtime-jira-client', () => ({ + jiraGetProjectStatusOrder: (...args: unknown[]) => jiraGetProjectStatusOrderMock(...args) +})) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + jiraGetProjectStatusOrderMock.mockReset() +}) + +function jiraIssue( + key: string, + title: string, + statusId: string, + statusName: string, + options: { projectKey?: string; siteId?: string } = {} +): JiraIssue { + const siteId = options.siteId ?? 'site-1' + const projectKey = options.projectKey ?? 'ALP' + return { + id: `${siteId}:${key}`, + key, + title, + url: `https://example.atlassian.net/browse/${key}`, + siteId, + siteName: 'Example Jira', + project: { id: projectKey, key: projectKey, name: projectKey, siteId }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: statusId, name: statusName, categoryKey: 'new', categoryName: statusName }, + labels: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z' + } +} + +function statusOrder(statusIdsByColumn: string[][]): JiraProjectStatusOrder { + return { statusIdsByColumn } +} + +describe('Jira issue status grouping', () => { + it('groups issues through the production implementation and preserves row order', () => { + const first = jiraIssue('ALP-1', 'First', '1', 'To Do') + const second = jiraIssue('ALP-2', 'Second', '2', 'In Progress') + const third = jiraIssue('ALP-3', 'Third', '1', 'To Do') + + const sections = groupJiraIssuesByStatus([first, second, third], null) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'To Do']) + expect(sections[1]?.issues).toEqual([first, third]) + }) + + it('uses Jira column order while sorting statuses in the same column alphabetically', () => { + const sections = groupJiraIssuesByStatus( + [ + jiraIssue('ALP-1', 'Done issue', '3', 'Done'), + jiraIssue('ALP-2', 'To do issue', '1', 'To Do'), + jiraIssue('ALP-3', 'Progress issue', '2', 'In Progress') + ], + statusOrder([['1', '2'], ['3']]) + ) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'To Do', 'Done']) + }) + + it('places statuses missing from board configuration last in alphabetical order', () => { + const sections = groupJiraIssuesByStatus( + [ + jiraIssue('ALP-1', 'Done issue', '3', 'Done'), + jiraIssue('ALP-2', 'To do issue', '1', 'To Do'), + jiraIssue('ALP-3', 'Progress issue', '2', 'In Progress') + ], + statusOrder([['2']]) + ) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'Done', 'To Do']) + }) + + it('only selects a board-order scope for one Jira site and project', () => { + const singleScope = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do'), + jiraIssue('ALP-2', 'Second', '2', 'In Progress') + ]) + const multipleSites = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do', { siteId: 'site-1' }), + jiraIssue('ALP-2', 'Second', '2', 'In Progress', { siteId: 'site-2' }) + ]) + const multipleProjects = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do', { projectKey: 'ALP' }), + jiraIssue('BRV-1', 'Second', '2', 'In Progress', { projectKey: 'BRV' }) + ]) + const missingSite = jiraIssue('ALP-3', 'Third', '3', 'Done') + delete missingSite.siteId + delete missingSite.project.siteId + + expect(singleScope).toMatchObject({ projectKey: 'ALP', siteId: 'site-1' }) + expect(multipleSites).toBeNull() + expect(multipleProjects).toBeNull() + expect(getSingleJiraProjectScope([missingSite])).toBeNull() + }) + + it('uses alphabetical fallback when status-order metadata is unavailable', async () => { + const error = new Error('Unknown RPC method') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + jiraGetProjectStatusOrderMock.mockRejectedValueOnce(error) + const scope = getSingleJiraProjectScope([jiraIssue('ALP-1', 'First', '1', 'To Do')]) + if (!scope) { + throw new Error('Expected one Jira project scope') + } + + await expect(loadTaskPageJiraProjectStatusOrder(null, 'runtime:old', scope)).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(warn).toHaveBeenCalledWith('[jira] Failed to load project status order:', error) + }) + + it('collapses and expands a status group through its accessible trigger', async () => { + const user = userEvent.setup() + render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskPageJiraIssueList, { + formatUpdatedAt: () => 'today', + getStatusTone: () => 'border-border', + issues: [ + jiraIssue('ALP-1', 'First issue', '1', 'To Do'), + jiraIssue('ALP-2', 'Second issue', '1', 'To Do') + ], + onOpenIssue: vi.fn(), + onStartWorkspace: vi.fn(), + selectedIssue: null, + showSiteContext: false, + statusOrder: null + }) + ) + ) + + const trigger = screen.getByRole('button', { name: 'To Do 2' }) + expect(trigger).toHaveAttribute('data-variant', 'ghost') + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('First issue')).toBeInTheDocument() + + await user.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('First issue')).not.toBeInTheDocument() + + await user.click(trigger) + expect(screen.getByText('First issue')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page-jira-issue-list.tsx new file mode 100644 index 00000000000..f8b67d1c20e --- /dev/null +++ b/src/renderer/src/components/task-page-jira-issue-list.tsx @@ -0,0 +1,349 @@ +import React, { useMemo, useState } from 'react' +import { ArrowRight, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' + +export type TaskPageJiraIssueSection = { + key: string + label: string + issues: JiraIssue[] +} + +type TaskPageJiraIssueListProps = { + formatUpdatedAt: (updatedAt: string) => string + getStatusTone: (categoryKey: string) => string + issues: JiraIssue[] + onOpenIssue: (issue: JiraIssue) => void + onStartWorkspace: (issue: JiraIssue) => void + selectedIssue: JiraIssue | null + showSiteContext: boolean + statusOrder: JiraProjectStatusOrder | null +} + +function statusColumnRanks(order: JiraProjectStatusOrder | null): Map { + const ranks = new Map() + for (const [columnIndex, statusIds] of (order?.statusIdsByColumn ?? []).entries()) { + for (const statusId of statusIds) { + if (!ranks.has(statusId)) { + ranks.set(statusId, columnIndex) + } + } + } + return ranks +} + +function sectionColumnRank( + section: TaskPageJiraIssueSection, + ranks: ReadonlyMap +): number { + let rank = Number.POSITIVE_INFINITY + for (const issue of section.issues) { + rank = Math.min(rank, ranks.get(issue.status.id) ?? Number.POSITIVE_INFINITY) + } + return rank +} + +export function groupJiraIssuesByStatus( + issues: readonly JiraIssue[], + statusOrder: JiraProjectStatusOrder | null +): TaskPageJiraIssueSection[] { + const sections = new Map() + for (const issue of issues) { + const key = `status:${issue.status.name}` + const section = sections.get(key) + if (section) { + section.issues.push(issue) + } else { + sections.set(key, { key, label: issue.status.name, issues: [issue] }) + } + } + + const ranks = statusColumnRanks(statusOrder) + const sectionRanks = new Map( + [...sections.values()].map((section) => [section.key, sectionColumnRank(section, ranks)]) + ) + return [...sections.values()].sort((a, b) => { + const rankA = sectionRanks.get(a.key) ?? Number.POSITIVE_INFINITY + const rankB = sectionRanks.get(b.key) ?? Number.POSITIVE_INFINITY + return rankA === rankB ? a.label.localeCompare(b.label) : rankA - rankB + }) +} + +function isSelectedIssue(issue: JiraIssue, selectedIssue: JiraIssue | null): boolean { + if (!selectedIssue || issue.key !== selectedIssue.key) { + return false + } + return !selectedIssue.siteId || !issue.siteId || selectedIssue.siteId === issue.siteId +} + +function JiraIssueRow({ + formatUpdatedAt, + getStatusTone, + issue, + onOpenIssue, + onStartWorkspace, + selected, + showSiteContext +}: { + formatUpdatedAt: (updatedAt: string) => string + getStatusTone: (categoryKey: string) => string + issue: JiraIssue + onOpenIssue: (issue: JiraIssue) => void + onStartWorkspace: (issue: JiraIssue) => void + selected: boolean + showSiteContext: boolean +}): React.JSX.Element { + const labels = issue.labels.slice(0, 3) + const contextLabel = + showSiteContext && issue.siteName + ? `${issue.siteName} / ${issue.project.key}` + : issue.project.key + + return ( + // Why: the row contains action buttons, so a native button wrapper would + // create invalid nested buttons; role + keyboard handling preserves access. +
onOpenIssue(issue)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) { + return + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpenIssue(issue) + } + }} + className={cn( + 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]', + selected && 'bg-accent' + )} + > + + {issue.key} + + +
+
+ + {issue.key} + +

+ {issue.title} +

+
+
+ + {issue.status.name} + + + {issue.priority?.name ?? + translate('auto.components.TaskPage.713179dfdc', 'No priority')} + + + {issue.assignee?.displayName ?? + translate('auto.components.TaskPage.42a9160321', 'Unassigned')} + +
+
+ + {contextLabel} + + {labels.map((label) => ( + + {label} + + ))} + {issue.labels.length > labels.length ? ( + + +{issue.labels.length - labels.length} + + ) : null} +
+
+ +
+ + {issue.status.name} + +
+ + + {issue.priority?.name ?? translate('auto.components.TaskPage.713179dfdc', 'No priority')} + + +
+ {issue.assignee?.avatarUrl ? ( + {issue.assignee.displayName} + ) : ( + + {issue.assignee?.displayName?.slice(0, 1) ?? '-'} + + )} + + {issue.assignee?.displayName ?? + translate('auto.components.TaskPage.42a9160321', 'Unassigned')} + +
+ + + +
+ {formatUpdatedAt(issue.updatedAt)} +
+
+ + {new Date(issue.updatedAt).toLocaleString()} + +
+ +
+ + + + + + {translate('auto.components.TaskPage.9497f2787c', 'Start workspace')} + + + + + + + + {translate('auto.components.TaskPage.eee68073b2', 'Open in Jira')} + + +
+
+ ) +} + +export function TaskPageJiraIssueList({ + formatUpdatedAt, + getStatusTone, + issues, + onOpenIssue, + onStartWorkspace, + selectedIssue, + showSiteContext, + statusOrder +}: TaskPageJiraIssueListProps): React.JSX.Element { + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) + const sections = useMemo( + () => groupJiraIssuesByStatus(issues, statusOrder), + [issues, statusOrder] + ) + + return ( +
+ {sections.map((section) => { + const open = !collapsedGroups.has(section.key) + return ( + { + setCollapsedGroups((current) => { + const next = new Set(current) + if (nextOpen) { + next.delete(section.key) + } else { + next.add(section.key) + } + return next + }) + }} + > + + + + + {section.issues.map((issue) => ( + + ))} + + + ) + })} +
+ ) +} diff --git a/src/renderer/src/components/task-page-jira-status-order.ts b/src/renderer/src/components/task-page-jira-status-order.ts new file mode 100644 index 00000000000..8cb6ed39c37 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-order.ts @@ -0,0 +1,63 @@ +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' +import { jiraGetProjectStatusOrder, type RuntimeJiraSettings } from '@/runtime/runtime-jira-client' +import { createMetadataRequestStore, loadMetadata } from '@/hooks/metadata-request-cache' + +export type TaskPageJiraProjectScope = { + key: string + projectKey: string + siteId: string +} + +const jiraProjectStatusOrderStore = createMetadataRequestStore() + +export function getTaskPageJiraStatusOrderScopeKey( + runtimeScopeKey: string, + projectScope: TaskPageJiraProjectScope +): string { + return `${encodeURIComponent(runtimeScopeKey)}:${projectScope.key}` +} + +function issueProjectScope(issue: JiraIssue): TaskPageJiraProjectScope | null { + const projectKey = issue.project.key.trim() + const siteId = issue.siteId?.trim() || issue.project.siteId?.trim() + if (!projectKey || !siteId) { + return null + } + return { + key: `${encodeURIComponent(siteId)}:${encodeURIComponent(projectKey)}`, + projectKey, + siteId + } +} + +export function getSingleJiraProjectScope( + issues: readonly JiraIssue[] +): TaskPageJiraProjectScope | null { + let onlyScope: TaskPageJiraProjectScope | null = null + for (const issue of issues) { + const scope = issueProjectScope(issue) + if (!scope || (onlyScope && onlyScope.key !== scope.key)) { + return null + } + onlyScope = scope + } + return onlyScope +} + +export function loadTaskPageJiraProjectStatusOrder( + settings: RuntimeJiraSettings, + runtimeScopeKey: string, + projectScope: TaskPageJiraProjectScope +): Promise { + // Why: a project key can exist on multiple Jira sites and runtimes; all three + // identities must participate in caching to preserve SSH and multi-site parity. + const cacheKey = getTaskPageJiraStatusOrderScopeKey(runtimeScopeKey, projectScope) + return loadMetadata(jiraProjectStatusOrderStore, cacheKey, () => + jiraGetProjectStatusOrder(settings, projectScope.projectKey, projectScope.siteId) + ).catch((error: unknown) => { + // Why: older remote runtimes do not expose this optional metadata method; + // an empty order preserves the deterministic alphabetical fallback. + console.warn('[jira] Failed to load project status order:', error) + return { statusIdsByColumn: [] } + }) +} diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index c4d5ca666df..812e28b8233 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -12,6 +12,7 @@ import type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSiteSelection, JiraTransition, JiraUser, @@ -280,3 +281,17 @@ export async function jiraListTransitions( ? callRuntimeRpc(target, 'jira.listTransitions', args, { timeoutMs: 30_000 }) : window.api.jira.listTransitions(args) } + +export async function jiraGetProjectStatusOrder( + settings: RuntimeJiraSettings, + projectKey: string, + siteId?: string | null +): Promise { + const target = getJiraRuntimeTarget(settings) + const args = { projectKey, siteId: siteId ?? undefined } + return target.kind === 'environment' + ? callRuntimeRpc(target, 'jira.getProjectStatusOrder', args, { + timeoutMs: 30_000 + }) + : window.api.jira.getProjectStatusOrder(args) +} diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 8ce07fde4d0..7831bd18416 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -81,6 +81,10 @@ export type JiraStatus = { colorName?: string } +export type JiraProjectStatusOrder = { + statusIdsByColumn: string[][] +} + export type JiraTransition = { id: string name: string diff --git a/src/shared/types.ts b/src/shared/types.ts index 3af1e7fdd8f..d6e04ef8513 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1844,6 +1844,7 @@ export type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSite, JiraSiteSelection, JiraStatus, From 4209889f6041775904589cb99f1cf666a0eaa1d9 Mon Sep 17 00:00:00 2001 From: BingZ Date: Sat, 11 Jul 2026 04:11:24 +0800 Subject: [PATCH 04/95] feat(status-bar): Antigravity usage status (#7996) * fix(status-bar): keep antigravity usage visible * feat(rate-limits): add Grok usage status * feat(grok): add managed usage accounts * test(rate-limits): isolate Codex PTY fallback fetches * fix(grok): address CodeRabbit review on usage status PR - Guard auth.json read/parse so missing files return null - Thread AbortSignal through Grok rate-limit fetch and ACP auth - Pass isRemote:false for floating-terminal agent launch env * fix(grok): wire ACP abort after child listeners to avoid TDZ * fix(status-bar): address CodeRabbit on combined Grok/Antigravity usage - Pin WSL shell distro when resolving managed GROK_HOME (parity with Codex) - Refresh only Grok on account change via fetchGrokOnly - Consolidate usage status-bar toggle catalogs; add Grok locale keys - Fix Grok aria-label, feature-interaction tracking, and test fixture * fix(settings): drop duplicate usage status-bar toggle catalog Remove the leftover re-export path so Appearance search has a single source of truth for usage provider toggles (including Antigravity + Grok). * fix(settings): restore AccountsPane and locale UTF-8 after merge Re-do AccountsPane three-way merge with binary-safe git objects and keep both Grok accounts + remote provider client imports. Rebuild locale JSON merges so em dash/ellipsis/middle-dot strings are no longer mojibake. * fix(i18n): add missing comma after merged status-bar locale keys * test: add antigravity to GrokUsagePane rate-limit fixture RateLimitState gained a required antigravity field; the pane fixture must carry it to typecheck. * fix(status-bar): gate Antigravity durable visibility on Gemini OAuth opt-in The Antigravity snapshot mirrors the Gemini fetch, which is permanently 'unavailable' until the user opts into Gemini CLI OAuth. Without this gate the default-on checked item plus a detected agy CLI pinned a dead 'A --' bar (Gemini itself hides in that state) and suppressed the usage setup CTA for users who configured nothing. --------- Co-authored-by: Brennan Benson --- src/main/rate-limits/service.ts | 14 ++- .../runtime/rpc/methods/client-ui.test.ts | 6 +- src/main/runtime/rpc/methods/client-ui.ts | 2 + .../settings/AppearancePane.test.tsx | 25 ++++ .../AppearanceWindowSidebarSection.tsx | 1 + ...ce-status-bar-antigravity-toggle-search.ts | 43 +++++++ .../appearance-status-bar-search.test.ts | 13 ++ .../settings/appearance-status-bar-search.ts | 2 + .../components/stats/GrokUsagePane.test.tsx | 1 + .../src/components/status-bar/StatusBar.tsx | 62 ++++++++-- .../status-bar-agent-gating.test.ts | 3 + .../status-bar/status-bar-agent-gating.ts | 3 +- .../status-bar-provider-visibility.test.ts | 111 ++++++++++++++++++ .../status-bar-provider-visibility.ts | 20 ++++ .../src/components/status-bar/tooltip.test.ts | 16 ++- .../src/components/status-bar/tooltip.tsx | 3 + .../status-bar/usage-error-copy.test.ts | 5 + .../components/status-bar/usage-error-copy.ts | 3 + src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../src/store/slices/rate-limits.test.ts | 19 +++ src/renderer/src/store/slices/rate-limits.ts | 1 + src/renderer/src/store/slices/ui.test.ts | 13 +- src/renderer/src/store/slices/ui.ts | 13 +- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/rate-limit-types.test.ts | 4 +- src/shared/rate-limit-types.ts | 3 +- src/shared/status-bar-defaults.ts | 1 + src/shared/types.ts | 3 + 32 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts create mode 100644 src/renderer/src/store/slices/rate-limits.test.ts diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index cbfd8289b7e..5c3243c87be 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -79,6 +79,7 @@ type InternalRateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null } @@ -113,6 +114,7 @@ export class RateLimitService { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null } @@ -1126,7 +1128,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' ): ProviderRateLimits { if (!current) { return { @@ -1200,6 +1202,7 @@ export class RateLimitService { ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), @@ -1287,6 +1290,14 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) + // Why: Antigravity shares Google/Gemini usage credentials today; mirror the + // Gemini snapshot under provider 'antigravity' so status-bar UI that checks + // antigravity state receives a real fetch lifecycle instead of staying null. + const antigravity: ProviderRateLimits = { + ...gemini, + provider: 'antigravity' + } + const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -1365,6 +1376,7 @@ export class RateLimitService { : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), minimax: shouldApplyMiniMax ? miniMaxConfigChanged ? miniMax diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2af71e3abf1..01a3f784e17 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -182,11 +182,12 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -222,11 +223,12 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 99595922838..9b08e6decd3 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -44,6 +44,7 @@ const StatusBarItem = z.enum([ 'claude', 'codex', 'gemini', + 'antigravity', 'opencode-go', 'kimi', 'minimax', @@ -204,6 +205,7 @@ const UiUpdate = z _portsStatusBarDefaultAdded: z.boolean().optional(), _kimiStatusBarDefaultAdded: z.boolean().optional(), _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 5fe949cea39..127df935ff7 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -412,6 +412,31 @@ describe('AppearancePane', () => { expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax') }) + it('records Antigravity status bar toggles as usage tracking interactions', async () => { + mocks.state.availableStatusBarToggles = [ + { + id: 'antigravity', + title: 'Antigravity Usage', + description: 'Show Antigravity subscription usage in the status bar.', + toggleDescription: 'Show Antigravity subscription usage for the active workspace.', + keywords: ['status bar', 'antigravity', 'usage'] + } + ] + mocks.state.settingsSearchQuery = 'antigravity' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const antigravitySwitch = container.querySelector( + 'button[role="switch"][aria-label="Antigravity Usage"]' + ) + + expect(antigravitySwitch).not.toBeNull() + await act(async () => { + antigravitySwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity') + }) + it('collapses sibling sections so only the Interface section is expanded by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 97d53297e0a..1690832ee18 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -43,6 +43,7 @@ function recordStatusBarToggleInteraction( id === 'gemini' || id === 'opencode-go' || id === 'kimi' || + id === 'antigravity' || id === 'minimax' || id === 'grok' ) { diff --git a/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts new file mode 100644 index 00000000000..68c6513bb72 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts @@ -0,0 +1,43 @@ +import type { StatusBarItem } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getAntigravityStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'antigravity', + title: translate( + 'auto.components.settings.appearance.search.antigravityUsageTitle', + 'Antigravity Usage' + ), + description: translate( + 'auto.components.settings.appearance.search.antigravityUsageDescription', + 'Show Antigravity subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.antigravityKeyword', + 'antigravity' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google') + ], + toggleDescription: translate( + 'settings.appearance.statusBar.antigravityToggleDescription', + 'Show Antigravity subscription usage for the active workspace.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts index 18a218aca4c..3518bcba8aa 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -18,6 +18,19 @@ vi.mock('./settings-search-keywords', () => ({ import { getStatusBarToggles } from './appearance-status-bar-search' describe('getStatusBarToggles', () => { + it('includes Antigravity usage so Appearance can toggle the default-on status item', () => { + const antigravityToggle = getStatusBarToggles().find((entry) => entry.id === 'antigravity') + + expect(antigravityToggle).toMatchObject({ + title: 'Antigravity Usage', + description: 'Show Antigravity subscription usage in the status bar.', + toggleDescription: 'Show Antigravity subscription usage for the active workspace.' + }) + expect(antigravityToggle?.keywords).toEqual( + expect.arrayContaining(['status bar', 'antigravity', 'usage', 'subscription', 'google']) + ) + }) + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 9044c084fa6..02403c24f4b 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -2,6 +2,7 @@ import type { StatusBarItem } from '../../../../shared/types' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-bar-antigravity-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( @@ -99,6 +100,7 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Gemini token and cost usage for the active workspace.' ) }, + getAntigravityStatusBarToggleSearchEntry(), { id: 'opencode-go', title: translate( diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx index 6e942363112..d1ef3700f83 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -22,6 +22,7 @@ const mockStoreState = { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: { provider: 'grok', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 3db1b663f24..71cc71ffe40 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1760,11 +1760,13 @@ export function ProviderDetailsMenu({ ? 'O' : provider.provider === 'kimi' ? 'K' - : provider.provider === 'minimax' - ? 'M' - : provider.provider === 'grok' - ? 'R' - : 'X'} + : provider.provider === 'antigravity' + ? 'A' + : provider.provider === 'minimax' + ? 'M' + : provider.provider === 'grok' + ? 'R' + : 'X'} ) : ( @@ -1910,17 +1912,26 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, minimax, grok } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while // usage snapshots hydrate, fail, or temporarily report unavailable. // Detection-gating (see status-bar-agent-gating) additionally hides per-CLI // bars when the agent isn't installed on PATH. - // Why: thread the cookie durability flag from RateLimitState so the - // MiniMax bar stays visible after a reload and between snapshot refreshes. + // Why: Antigravity usage has no separate persisted credential. A checked + // status item plus detected CLI is the durable signal that the user wants + // its usage slot visible while the first snapshot is still pending. The + // visibility module additionally requires geminiCliOAuthEnabled (already in + // settings) because the snapshot mirrors the Gemini fetch. + const antigravityUsageConfigured = + statusBarItems.includes('antigravity') && + isStatusBarItemAvailable('antigravity', detectedAgentIds) + // Why: thread non-GlobalSettings durability flags from renderer/main state so + // bars stay visible after a reload and between snapshot refreshes. const usageSettings = { ...settings, + antigravityUsageConfigured, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, grokAuthConfigured: rateLimits.grokAuthConfigured } @@ -1928,6 +1939,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) + const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const showClaude = @@ -1946,6 +1958,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + const showAntigravity = + visibleAntigravity !== null && + statusBarItems.includes('antigravity') && + isStatusBarItemAvailable('antigravity', detectedAgentIds) // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating // doesn't apply (same rationale as OpenCode Go below). const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') @@ -1968,6 +1984,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showGemini || showOpencodeGo || showKimi || + showAntigravity || showMiniMax || showGrok || showResourceUsage @@ -1976,7 +1993,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, minimax, grok }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, usageSettings ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it @@ -1988,6 +2005,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || + antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' @@ -2045,6 +2063,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showAntigravity && ( + + )} {showOpencodeGo && ( )} + {isStatusBarItemAvailable('antigravity', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('antigravity') + }} + > + + {translate( + 'auto.components.status.bar.StatusBar.antigravityUsage', + 'Antigravity Usage' + )} + + )} { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index b888a52fb05..cd98315df50 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -18,6 +18,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) expect(isStatusBarItemAvailable('gemini', null)).toBe(true) + expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) @@ -25,6 +26,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) + expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) @@ -32,6 +34,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) + expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 805d7079cee..4ded63147db 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -1,6 +1,6 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types' -// Why: Claude/Codex/Gemini usage bars are surface noise when the underlying +// Why: CLI-backed usage bars are surface noise when the underlying // CLI isn't installed (e.g. a fresh Ubuntu install showing "Gemini Usage" // when no Gemini CLI is on PATH). We hide both the bar and its toggle when // PATH detection reports the agent as missing. Pre-detection (null) keeps @@ -11,6 +11,7 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'codex', 'gemini', 'kimi', + 'antigravity', 'grok' ]) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 2dbba23155a..7389a372f80 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, + antigravityUsageConfigured: false, minimaxCookieConfigured: false, grokAuthConfigured: false, ...overrides @@ -119,6 +120,11 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) + // Why: antigravity durability requires the Gemini OAuth opt-in; the + // checked item alone must not suppress the usage setup CTA. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( + false + ) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) @@ -150,6 +156,32 @@ describe('hasUsageProviderSettingsForProvider', () => { ).toBe(true) expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) + }) + + it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + ).toBe(true) + // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it + // is permanently unavailable, so the checked item alone is not durable. + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(false) + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ geminiCliOAuthEnabled: true }) + ) + ).toBe(false) + expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('antigravity', null)).toBe(false) }) it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => { @@ -283,6 +315,42 @@ describe('getVisibleUsageProvider', () => { ) ).toBe(null) }) + + it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + const visible = getVisibleUsageProvider( + 'antigravity', + null, + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + expect(visible).toMatchObject({ + provider: 'antigravity', + status: 'fetching', + session: null, + weekly: null + }) + }) + + it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { + // Why: without the OAuth opt-in the mirrored snapshot is permanently + // 'unavailable'; the default-on item must not pin a dead bar. + expect( + getVisibleUsageProvider( + 'antigravity', + null, + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(null) + expect( + getVisibleUsageProvider( + 'antigravity', + provider('unavailable', { + provider: 'antigravity', + error: 'Gemini CLI OAuth is disabled in settings' + }), + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(null) + }) }) describe('isUsageEmptyState', () => { @@ -295,6 +363,7 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null }, @@ -312,6 +381,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -329,6 +399,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -357,6 +428,7 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null }, @@ -374,6 +446,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -381,4 +454,42 @@ describe('isUsageEmptyState', () => { ) ).toBe(true) }) + + it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { + expect( + isUsageEmptyState( + { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + grok: provider('unavailable', { provider: 'grok' }), + minimax: provider('unavailable', { provider: 'minimax' }) + }, + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + ).toBe(false) + }) + + it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { + // Why: the default-on Antigravity item is not configured usage on its own; + // it must not hide the teaching CTA from users who set nothing up. + expect( + isUsageEmptyState( + { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + grok: provider('unavailable', { provider: 'grok' }), + minimax: provider('unavailable', { provider: 'minimax' }) + }, + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 54e7ad1a362..1da3dc0f757 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,6 +8,12 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { + // Why: Antigravity has no separate persisted usage credential in Orca. The + // checked status-bar item is the durable user signal; StatusBar only sets + // this after PATH detection says the agent is available. Durability further + // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, + // which never yields data while that opt-in is off. + antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean grokAuthConfigured: boolean @@ -19,6 +25,7 @@ type UsageProviderSnapshots = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null } @@ -65,6 +72,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || + // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is + // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || settings?.grokAuthConfigured === true ) @@ -89,6 +98,12 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } + if (providerId === 'antigravity') { + // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays + // 'unavailable' until the user opts into Gemini CLI OAuth. Without that + // gate the default-on checked item would pin a permanently dead bar. + return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true } @@ -137,12 +152,16 @@ export function isUsageEmptyState( // Why: system-default Claude/Codex accounts have no persisted account row; // their first durable signal is the usage snapshot, so wait for snapshots to // settle before teaching the user to connect an account. + const antigravitySnapshotPending = + hasUsageProviderSettingsForProvider('antigravity', settings) && + isProviderSnapshotPending(providers.antigravity) if ( isProviderSnapshotPending(providers.claude) || isProviderSnapshotPending(providers.codex) || isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || + antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) ) { @@ -155,6 +174,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && + !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) ) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 510e3eea66f..dea9af3abd7 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -1,10 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' +import type * as ReactModule from 'react' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -vi.mock('@/lib/agent-catalog', () => ({ - AgentIcon: () => null -})) +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = await vi.importActual('react') + return { + AgentIcon: ({ agent }: { agent: string }) => + ReactActual.createElement('span', { 'data-agent-icon': agent }) + } +}) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => { @@ -448,6 +453,11 @@ describe('ProviderPanel reset rendering', () => { }) describe('ProviderIcon', () => { + it('renders the Antigravity agent icon for the antigravity provider', () => { + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'antigravity' })) + expect(markup).toContain('data-agent-icon="antigravity"') + }) + it('renders the official MiniMax icon asset for the minimax provider', () => { // Why: the icon must travel to the status bar / tooltip unchanged so the // user recognises the brand. We pin it to an with a non-empty diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c013c110463..be174965b4f 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -93,6 +93,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'kimi') { return } + if (provider === 'antigravity') { + return + } if (provider === 'minimax') { return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts index 3fc29780adc..c91d0704ace 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.test.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -7,6 +7,10 @@ vi.mock('@/i18n/i18n', () => ({ import { getProviderDisplayName } from './usage-error-copy' describe('getProviderDisplayName', () => { + it('returns the Antigravity brand name', () => { + expect(getProviderDisplayName('antigravity')).toBe('Antigravity') + }) + it('returns the MiniMax brand name', () => { expect(getProviderDisplayName('minimax')).toBe('MiniMax') }) @@ -17,6 +21,7 @@ describe('getProviderDisplayName', () => { expect(getProviderDisplayName('gemini')).toBe('Gemini') expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') expect(getProviderDisplayName('kimi')).toBe('Kimi') + expect(getProviderDisplayName('grok')).toBe('Grok') }) it('falls back to the raw provider id when no mapping exists', () => { diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index f6f9b8d699c..83fc1888beb 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'antigravity') { + return 'Antigravity' + } if (provider === 'minimax') { return 'MiniMax' } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c8045033f8..004a9d9e3e9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29,6 +29,7 @@ "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", + "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "Open MiniMax usage details", "3bbf140864": "MiniMax Usage", "remoteServerLabel": "Remote server", + "antigravityUsage": "Antigravity Usage", + "antigravityUsageDetails": "Open Antigravity usage details", "grokUsageAria": "Open Grok usage details", "grokUsageMenu": "Grok Usage" }, @@ -7223,6 +7226,9 @@ "workspaceOptions": "workspace options", "detailed": "detailed" }, + "antigravityUsageTitle": "Antigravity Usage", + "antigravityUsageDescription": "Show Antigravity subscription usage in the status bar.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok Usage", "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", "d6c0a9e2f4": "grok", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ffd31bdd327..b8d4ca45d95 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -29,6 +29,7 @@ "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", + "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "Abrir detalles de uso de MiniMax", "3bbf140864": "Uso de MiniMax", "remoteServerLabel": "Servidor remoto", + "antigravityUsage": "Uso de Antigravity", + "antigravityUsageDetails": "Abrir detalles de uso de Antigravity", "grokUsageAria": "Abrir detalles de uso de Grok", "grokUsageMenu": "Uso de Grok" }, @@ -7186,6 +7189,9 @@ }, "9a115966d3": "Minimizar a la bandeja al cerrar", "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "antigravityUsageTitle": "Uso de Antigravity", + "antigravityUsageDescription": "Muestra el uso de suscripción de Antigravity en la barra de estado.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Uso de Grok", "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", "d6c0a9e2f4": "grok", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 360123c88e7..d35dc5d81ef 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -29,6 +29,7 @@ "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", + "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "MiniMax の使用量詳細を開く", "3bbf140864": "MiniMax 使用量", "remoteServerLabel": "リモートサーバー", + "antigravityUsage": "Antigravity の使用状況", + "antigravityUsageDetails": "Antigravity の使用状況詳細を開く", "grokUsageAria": "Grok の使用状況詳細を開く", "grokUsageMenu": "Grok の使用状況" }, @@ -7208,6 +7211,9 @@ }, "9a115966d3": "閉じるときにトレイへ最小化", "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "antigravityUsageTitle": "Antigravity の使用状況", + "antigravityUsageDescription": "ステータスバーに Antigravity サブスクリプションの使用量を表示します。", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok の使用状況", "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", "d6c0a9e2f4": "grok", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c74f362a770..390d5836d5a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -29,6 +29,7 @@ "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", + "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "MiniMax 사용량 세부 정보 열기", "3bbf140864": "MiniMax 사용량", "remoteServerLabel": "원격 서버", + "antigravityUsage": "Antigravity 사용량", + "antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기", "grokUsageAria": "Grok 사용량 세부 정보 열기", "grokUsageMenu": "Grok 사용량" }, @@ -7171,6 +7174,9 @@ "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" }, + "antigravityUsageTitle": "Antigravity 사용량", + "antigravityUsageDescription": "상태 표시줄에 Antigravity 구독 사용량을 표시합니다.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok 사용량", "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", "d6c0a9e2f4": "grok", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3796138b5fd..dee19362aa3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -29,6 +29,7 @@ "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。", + "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "打开 MiniMax 使用量详情", "3bbf140864": "MiniMax 使用量", "remoteServerLabel": "远程服务器", + "antigravityUsage": "Antigravity 使用量", + "antigravityUsageDetails": "打开 Antigravity 使用详情", "grokUsageAria": "打开 Grok 使用详情", "grokUsageMenu": "Grok 使用量" }, @@ -7171,6 +7174,9 @@ }, "9a115966d3": "关闭时最小化到托盘", "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "antigravityUsageTitle": "Antigravity 使用量", + "antigravityUsageDescription": "在状态栏中显示 Antigravity 订阅使用量。", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok 使用量", "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", "d6c0a9e2f4": "grok", diff --git a/src/renderer/src/store/slices/rate-limits.test.ts b/src/renderer/src/store/slices/rate-limits.test.ts new file mode 100644 index 00000000000..9772483f9e4 --- /dev/null +++ b/src/renderer/src/store/slices/rate-limits.test.ts @@ -0,0 +1,19 @@ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { describe, expect, it } from 'vitest' +import { createRateLimitSlice } from './rate-limits' +import type { AppState } from '../types' + +function createRateLimitStore(): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => + createRateLimitSlice(...(args as Parameters)) + ) as unknown as StoreApi +} + +describe('createRateLimitSlice', () => { + it('initializes Antigravity usage with a stable pending key', () => { + const store = createRateLimitStore() + + expect(store.getState().rateLimits.antigravity).toBeNull() + }) +}) diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 37bd643833a..3df495cf32d 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -22,6 +22,7 @@ export const createRateLimitSlice: StateCreator { 'ports', 'kimi', 'minimax', + 'antigravity', 'grok' ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax', 'grok'], + statusBarItems: [ + 'claude', + 'resource-usage', + 'ports', + 'kimi', + 'minimax', + 'antigravity', + 'grok' + ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) }) @@ -1241,6 +1251,7 @@ describe('createUISlice hydratePersistedUI', () => { _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 321f9ce09a3..f5d2432a900 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -265,6 +265,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { @@ -2264,14 +2265,19 @@ export const createUISlice: StateCreator = (set, get) ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') ? statusBarItems : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] - const statusBarItemsWithGrok = - ui._grokStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('grok') + const statusBarItemsWithAntigravity = + ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') ? statusBarItemsWithMiniMax - : [...statusBarItemsWithMiniMax, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] + const statusBarItemsWithGrok = + ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') + ? statusBarItemsWithAntigravity + : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] if ( (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || !ui._minimaxStatusBarDefaultAdded || + !ui._antigravityStatusBarDefaultAdded || !ui._grokStatusBarDefaultAdded) && typeof window !== 'undefined' ) { @@ -2281,6 +2287,7 @@ export const createUISlice: StateCreator = (set, get) _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) .catch(console.error) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 62df42b60d2..4bd602c7634 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2587,6 +2587,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null, minimaxCookieConfigured: false, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 255965ec034..6d35c2d22fc 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { RateLimitState } from './rate-limit-types' describe('RateLimitState', () => { - it('documents the MiniMax surface used by the AccountsPane settings UI', () => { + it('documents provider surfaces used by settings and status-bar UI', () => { // Why: the AccountsPane and the status bar both read these fields // from RateLimitState. The shape must stay stable so that the // visibility check (status-bar-provider-visibility) keeps working @@ -13,6 +13,7 @@ describe('RateLimitState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null, minimaxCookieConfigured: false, @@ -23,6 +24,7 @@ describe('RateLimitState', () => { inactiveCodexAccounts: [] } + expect(state.antigravity).toBeNull() expect(state.minimax).toBeNull() expect(state.minimaxCookieConfigured).toBe(false) }) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index d19699dee01..5470e851506 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -109,6 +109,7 @@ export type RateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null /** diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 0d48a6364f7..b1884262d7d 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -4,6 +4,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'claude', 'codex', 'gemini', + 'antigravity', 'opencode-go', 'kimi', 'minimax', diff --git a/src/shared/types.ts b/src/shared/types.ts index d6e04ef8513..8cc2d2eef9c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3152,6 +3152,7 @@ export type StatusBarItem = | 'claude' | 'codex' | 'gemini' + | 'antigravity' | 'opencode-go' | 'kimi' | 'minimax' @@ -3271,6 +3272,8 @@ export type PersistedUIState = { _kimiStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on MiniMax status item. */ _minimaxStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Antigravity status item. */ + _antigravityStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Grok status item. */ _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] From 20742848598617d5dca2c49430f2940fc2869ecd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:14:07 +0000 Subject: [PATCH 05/95] release: v1.4.135-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 757b3d7e22c..a45b19a9457 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.133", + "version": "1.4.135-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 96d1fa1d6292858a921b61ae2e58913ff0420134 Mon Sep 17 00:00:00 2001 From: BingZ Date: Sat, 11 Jul 2026 04:16:55 +0800 Subject: [PATCH 06/95] fix(grok): clipboard, native chat, hooks, sessions, ConPTY KKP (#7944) * fix(grok): restore clipboard and native-chat parity Grok CLI already supports argv prompts, OSC 52 copy, and image paste chips. Orca was blocking those paths: stdin-after-start keystroke injection, OSC 52 writes default-off, image-attachment denylist, and native-chat allowlist. - Launch Grok with positional argv prompts - Default OSC 52 TUI clipboard writes on (still user-toggleable) - Treat Grok as image-attachment capable - Parse ~/.grok/.../chat_history.jsonl for native chat OSC 52 clipboard *query* remains ignored by design (host clipboard exfil risk); xAI docs only require OSC 52 write for remote copy. * fix(grok): sync OSC 52 docs and locale catalog with default-on Update terminalAllowOsc52Clipboard type docs for the true default, and refresh locale strings so settings UI mentions Grok alongside other TUIs. * fix(grok): tool hook matcher, StopFailure, previews, AskUser waiting Grok tool-event matchers are real regexes; bare `*` failed as match-all. Install `.*` for Pre/Post tool hooks, add StopFailure for API-error ends, recognize Grok-native tool input keys, and map ask_user_question PreToolUse to waiting with interactivePrompt (Kimi-style live card path). * fix(grok): resolve chat_history under GROK_HOME and long-cwd layouts Centralize Grok session path helpers so hooks and native-chat honor GROK_HOME and find chat_history.jsonl by session id when the cwd group is slug-encoded (encoded name > 255 bytes) instead of only encodeURIComponent(cwd). * fix(terminal): keep Kitty keyboard for Grok on Windows ConPTY Local Windows ConPTY withholds KKP so CSI-u-blind CLIs (e.g. Antigravity) keep Enter/nav working (#2434). Grok needs KKP for Ctrl+Enter interject and modified-Enter newline chords; blanking the advertisement for Orca-launched Grok left those actions broken. - Prefer KKP when tuiAgent is grok despite ConPTY withhold - Wire launchAgent from tab/startup into keyboard protocol options * fix(grok): restore OSC52 default-off, split decoders, honor GROK_HOME hooks - Keep terminalAllowOsc52Clipboard default false (clipboard exfil risk) - Split transcript-line-decoders under max-lines without suppressions - Install local Grok hooks under resolveGrokHomeDir() / GROK_HOME * refactor(grok): share CLI home resolution * fix(grok): harden terminal and native chat integration * test(grok): align CI coverage with native chat support --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- .../remote-hook-service-installers.test.ts | 35 +- .../remote-managed-hook-installers.ts | 8 +- .../agent-hooks/server-grok-discovery.test.ts | 119 ++++++ src/main/agent-hooks/server.test.ts | 2 + src/main/agent-hooks/server.ts | 70 +++- .../session-scanner-source-discovery.ts | 6 +- .../runtime-home-service.test.ts | 2 +- src/main/codex-accounts/service.test.ts | 2 +- src/main/grok/hook-service.test.ts | 52 ++- src/main/grok/hook-service.ts | 87 +++- .../native-chat/session-file-resolver.test.ts | 74 +++- src/main/native-chat/session-file-resolver.ts | 23 ++ .../transcript-fallback-id.test.ts | 22 + .../native-chat/transcript-fallback-id.ts | 10 + .../transcript-line-decoders-claude.ts | 55 +++ .../transcript-line-decoders-codex.ts | 139 +++++++ .../transcript-line-decoders-grok.ts | 247 +++++++++++ .../transcript-line-decoders.grok.test.ts | 198 +++++++++ .../native-chat/transcript-line-decoders.ts | 183 +-------- src/main/native-chat/transcript-reader.ts | 32 +- .../transcript-stream-lines.test.ts | 49 +++ .../native-chat/transcript-stream-lines.ts | 47 +++ src/main/native-chat/transcript-watch.ts | 59 +-- src/main/providers/ssh-pty-provider.test.ts | 33 +- src/main/providers/ssh-pty-provider.ts | 36 +- src/main/rate-limits/grok-auth.ts | 4 +- src/main/ssh/ssh-relay-session.test.ts | 60 ++- src/main/ssh/ssh-relay-session.ts | 70 +++- src/relay/agent-hook-server.test.ts | 110 +++++ src/relay/agent-hook-server.ts | 84 +++- src/relay/pty-handler.test.ts | 235 ++++++++++- src/relay/pty-handler.ts | 94 ++++- .../native-chat/NativeChatView.test.tsx | 2 +- .../native-chat-availability.test.ts | 51 ++- .../native-chat/native-chat-availability.ts | 9 +- .../native-chat-image-paste.test.ts | 8 + .../native-chat/native-chat-image-paste.ts | 6 +- .../native-chat-pane-resolution.test.ts | 54 ++- .../use-native-chat-toggle-shortcut.ts | 7 +- .../NativeChatExperimentalSetting.tsx | 6 +- .../settings/TerminalInteractionSection.tsx | 5 +- .../native-chat-experimental-search-entry.ts | 6 +- .../src/components/tab-bar/TabBar.tsx | 6 + .../components/terminal-pane/TerminalPane.tsx | 6 + .../terminal-pane/pty-connection.test.ts | 18 + ...minal-keyboard-protocol-pane-agent.test.ts | 45 ++ .../terminal-keyboard-protocol-pane-agent.ts | 16 + .../use-terminal-pane-lifecycle.ts | 11 +- src/renderer/src/hooks/useIpcEvents.ts | 13 +- src/renderer/src/i18n/locales/en.json | 13 +- src/renderer/src/i18n/locales/es.json | 13 +- src/renderer/src/i18n/locales/ja.json | 13 +- src/renderer/src/i18n/locales/ko.json | 13 +- src/renderer/src/i18n/locales/zh.json | 13 +- .../src/i18n/native-chat-locales.test.ts | 27 ++ .../lib/agent-launch-prompt-delivery.test.ts | 4 +- .../src/lib/launch-agent-in-new-tab.test.ts | 33 +- .../src/lib/launch-agent-in-new-tab.ts | 8 +- .../lib/native-chat-initial-view-mode.test.ts | 39 +- .../src/lib/native-chat-initial-view-mode.ts | 8 +- .../src/lib/native-chat-supported-agent.ts | 6 +- ...native-chat-transcript-readability.test.ts | 11 + .../lib/native-chat-transcript-readability.ts | 8 + .../lib/pane-manager/pane-lifecycle.test.ts | 19 + .../terminal-keyboard-protocol.test.ts | 60 ++- .../terminal-keyboard-protocol.ts | 24 ++ .../src/lib/tui-agent-startup.test.ts | 6 +- src/renderer/src/lib/worktree-activation.ts | 12 +- src/shared/agent-hook-listener.test.ts | 267 +++++++++++- src/shared/agent-hook-listener.ts | 245 ++++++++--- src/shared/constants.ts | 4 + src/shared/grok-session-path-lookup-queue.ts | 158 +++++++ src/shared/grok-session-paths.test.ts | 387 ++++++++++++++++++ src/shared/grok-session-paths.ts | 294 +++++++++++++ src/shared/terminal-query-reply.test.ts | 18 + src/shared/tui-agent-config.ts | 11 +- src/shared/tui-agent-startup.test.ts | 57 +++ src/shared/tui-agent-startup.ts | 3 +- src/shared/types.ts | 4 +- 79 files changed, 3802 insertions(+), 492 deletions(-) create mode 100644 src/main/agent-hooks/server-grok-discovery.test.ts create mode 100644 src/main/native-chat/transcript-fallback-id.test.ts create mode 100644 src/main/native-chat/transcript-fallback-id.ts create mode 100644 src/main/native-chat/transcript-line-decoders-claude.ts create mode 100644 src/main/native-chat/transcript-line-decoders-codex.ts create mode 100644 src/main/native-chat/transcript-line-decoders-grok.ts create mode 100644 src/main/native-chat/transcript-line-decoders.grok.test.ts create mode 100644 src/main/native-chat/transcript-stream-lines.test.ts create mode 100644 src/main/native-chat/transcript-stream-lines.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts create mode 100644 src/renderer/src/i18n/native-chat-locales.test.ts create mode 100644 src/renderer/src/lib/native-chat-transcript-readability.test.ts create mode 100644 src/renderer/src/lib/native-chat-transcript-readability.ts create mode 100644 src/shared/grok-session-path-lookup-queue.ts create mode 100644 src/shared/grok-session-paths.test.ts create mode 100644 src/shared/grok-session-paths.ts diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 79539599922..90726a2d7a7 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -411,6 +411,7 @@ describe('remote hook service installers', () => { 'SessionStart', 'UserPromptSubmit', 'Stop', + 'StopFailure', 'SessionEnd', 'PreToolUse', 'PostToolUse', @@ -422,7 +423,10 @@ describe('remote hook service installers', () => { expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') expect(command).toMatch(/^if \[ -x /) } - expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*') + // Why: Grok tool matchers are real regexes; bare `*` is invalid match-all. + expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') + expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') + expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined() const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as { permissions: { mode: string } @@ -451,6 +455,35 @@ describe('remote hook service installers', () => { expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin') }) + it('installs remote Grok config in the explicit guest GROK_HOME', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new GrokHookService().installRemote( + sftp, + '/home/dev', + '/srv/grok profile/' + ) + + expect(status.configPath).toBe('/srv/grok profile/hooks/orca-status.json') + expect(fs.files.has('/srv/grok profile/hooks/orca-status.json')).toBe(true) + expect(fs.files.has('/home/dev/.grok/hooks/orca-status.json')).toBe(false) + const script = fs.files.get('/home/dev/.orca/agent-hooks/grok-hook.sh')! + expect(script).toContain('${#GROK_HOME}" -le 4096') + expect(script).toContain('--data-urlencode "grokHome=${grok_home}"') + }) + + it.each(['relative/grok', '/bad\\grok', `/${'x'.repeat(4096)}`])( + 'falls back to login-home Grok config for invalid remote home %s', + async (remoteGrokHome) => { + const { sftp, fs } = createFakeSftp() + + const status = await new GrokHookService().installRemote(sftp, '/home/dev', remoteGrokHome) + + expect(status.configPath).toBe('/home/dev/.grok/hooks/orca-status.json') + expect(fs.files.has('/home/dev/.grok/hooks/orca-status.json')).toBe(true) + } + ) + it('installs remote Kimi hooks as a managed config.toml block preserving user config', async () => { const userConfig = 'default_model = "kimi-k2.6"\n\n[providers."mine"]\napi_key = "sk-secret"\n' const { sftp, fs } = createFakeSftp({ '/home/dev/.kimi-code/config.toml': userConfig }) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 0151f63755c..39507f091f2 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -21,6 +21,8 @@ export type RemoteManagedHookInstallOptions = { * defers the config.toml trust write until that file exists, so the * launch path's only-if-absent seed is never pre-empted. */ codexHomeDir?: string + /** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */ + grokHomeDir?: string } type RemoteManagedHookInstaller = readonly [ @@ -52,7 +54,11 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)], - ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], + [ + 'grok', + (sftp, remoteHome, options) => + grokHookService.installRemote(sftp, remoteHome, options?.grokHomeDir) + ], ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts new file mode 100644 index 00000000000..cea007c3d2f --- /dev/null +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as agentHookListener from '../../shared/agent-hook-listener' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +function hookBody(payload: Record): Record { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + payload + } +} + +async function postGrokHook( + endpoint: { port: string; token: string }, + payload: Record +): Promise { + return fetch(`http://127.0.0.1:${endpoint.port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify(hookBody(payload)) + }) +} + +describe('AgentHookServer Grok discovery retries', () => { + it('waits for delayed discovery beyond the transcript retry window', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const server = new AgentHookServer() + const root = mkdtempSync(join(tmpdir(), 'orca-grok-delayed-discovery-')) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf532' + const cwd = join(root, 'workspace') + const sessionDir = join(root, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + const history = join(sessionDir, 'chat_history.jsonl') + writeFileSync(history, '') + vi.stubEnv('HOME', root) + vi.stubEnv('USERPROFILE', root) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const endpoint = { port: env.ORCA_AGENT_HOOK_PORT, token: env.ORCA_AGENT_HOOK_TOKEN } + const listener = vi.fn() + server.setListener(listener) + + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'delayed result' }) + await postGrokHook(endpoint, { hookEventName: 'Stop', sessionId, cwd }) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(listener.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + history, + `${JSON.stringify({ type: 'assistant', content: 'Found after discovery.' })}\n` + ) + releaseDiscovery() + + await vi.waitFor(() => { + expect(listener.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( + 'Found after discovery.' + ) + }) + } finally { + server.stop() + rmSync(root, { recursive: true, force: true }) + } + }) + + it('does not overwrite a newer same-text prompt when delayed discovery completes', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const server = new AgentHookServer() + const root = mkdtempSync(join(tmpdir(), 'orca-grok-stale-discovery-')) + vi.stubEnv('HOME', root) + vi.stubEnv('USERPROFILE', root) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const endpoint = { port: env.ORCA_AGENT_HOOK_PORT, token: env.ORCA_AGENT_HOOK_TOKEN } + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'old prompt' }) + await postGrokHook(endpoint, { + hookEventName: 'Stop', + sessionId: '019e37f4-5135-7b63-a4ab-6d13aa6bf533', + cwd: join(root, 'workspace') + }) + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'old prompt' }) + + releaseDiscovery() + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'old prompt', agentType: 'grok' }) + ]) + } finally { + server.stop() + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 91b0dc66051..bf3cf0c8f84 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -5305,6 +5305,8 @@ describe('Copilot hook normalization', () => { }) ) + // Let the first 50ms retry miss so continuation across SessionEnd is proven. + await new Promise((resolve) => setTimeout(resolve, 70)) writeFileSync( transcriptPath, `${JSON.stringify({ diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 167331765cf..da33b4231cb 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -31,6 +31,7 @@ import { parseFormEncodedBody, readRequestBody, resolveHookSource, + preparePendingGrokResultDiscovery, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -804,7 +805,8 @@ export class AgentHookServer { source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload, - attempt = 1 + attempt = 1, + discoveryReady = false ): void { if ( original.payload.lastAssistantMessage || @@ -814,28 +816,27 @@ export class AgentHookServer { return } this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded transcript-flush + // timers. Its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } const timer = setTimeout(() => { try { this.assistantMessageRetryTimers.delete(original.paneKey) - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, attempt + 1) - return - } - // Why: some agents POST Stop before their transcript/chat-history line - // is flushed. Retry from a timer so the hook request returns immediately. - this.applyNormalizedStatus(normalized) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) } catch (err) { console.error('[agent-hooks] assistant message retry failed:', err) } @@ -846,6 +847,35 @@ export class AgentHookServer { } } + private applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript/chat-history line is + // flushed. Discovery is event-driven; subsequent content retries stay timed. + this.applyNormalizedStatus(normalized) + } + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { this.paneKeyAliasPersistenceListener = listener } diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index acf573f4964..f77404bfc5a 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -8,6 +8,7 @@ import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi- import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' +import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -19,10 +20,7 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const GROK_SESSIONS_DIR = join( - process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), - 'sessions' -) +const GROK_SESSIONS_DIR = resolveGrokSessionsDir() const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9447e81ef75..9958e0fe867 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -79,7 +79,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalRightClickToPaste: false, terminalFocusFollowsMouse: false, terminalClipboardOnSelect: false, - terminalAllowOsc52Clipboard: false, + terminalAllowOsc52Clipboard: true, setupScriptLaunchMode: 'split-vertical', terminalScrollbackRows: 5_000, localAccountRuntime: 'host', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index abcafe1b438..a7cf0d033e2 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -84,7 +84,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalRightClickToPaste: false, terminalFocusFollowsMouse: false, terminalClipboardOnSelect: false, - terminalAllowOsc52Clipboard: false, + terminalAllowOsc52Clipboard: true, setupScriptLaunchMode: 'split-vertical', terminalScrollbackRows: 5_000, localAccountRuntime: 'host', diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 23cc2eeaf5f..c6f89509e16 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -15,7 +15,7 @@ vi.mock('os', async () => { } }) -import { GrokHookService } from './hook-service' +import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -55,12 +55,25 @@ describe('GrokHookService', () => { 'SessionEnd', 'SessionStart', 'Stop', + 'StopFailure', 'UserPromptSubmit' ].sort() ) - expect(config.hooks.PreToolUse[0].matcher).toBe('*') - expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') + // Why: Grok matchers are real regexes; bare `*` does not match-all. + expect(config.hooks.PreToolUse[0].matcher).toBe('.*') + expect(config.hooks.PostToolUseFailure[0].matcher).toBe('.*') + expect(config.hooks.PostToolUse[0].matcher).toBe('.*') + // Why: StopFailure must not carry a tool matcher — lifecycle-only event. + expect(config.hooks.StopFailure[0].matcher).toBeUndefined() expect(config.hooks.Notification[0].matcher).toBeUndefined() + // Why: assert the shipped helper still matches what install wrote (regression + // guard if GROK_TOOL_EVENT_MATCHER drifts from install). + expect(getGrokToolEventMatcherForTests()).toBe('.*') + expect(getGrokToolEventMatcherForTests()).not.toBe('*') + expect(new RegExp(getGrokToolEventMatcherForTests()).test('run_terminal_command')).toBe(true) + // Why: build the invalid pattern at runtime so static lint does not flag it. + const bareStar = ['*', ''].join('') + expect(() => new RegExp(bareStar)).toThrow() expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /grok-hook/ ) @@ -75,12 +88,20 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain( + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' + ) + expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive). expect(script).toContain('payload=$(cat)') expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('${#GROK_HOME}" -le 4096') + expect(script).toContain('--data-urlencode "grokHome=${grok_home}"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') } }) @@ -112,6 +133,31 @@ describe('GrokHookService', () => { } ) + it('installs hooks under GROK_HOME when set', () => { + const grokHome = mkdtempSync(join(tmpdir(), 'orca-grok-home-env-')) + const previous = process.env.GROK_HOME + process.env.GROK_HOME = grokHome + try { + const status = new GrokHookService().install() + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(grokHome, 'hooks', 'orca-status.json')) + expect(readFileSync(join(grokHome, 'hooks', 'orca-status.json'), 'utf8')).toContain( + 'SessionStart' + ) + // Why: must not also write into the mocked ~/.grok when GROK_HOME wins. + expect(() => + readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ).toThrow() + } finally { + if (previous === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previous + } + rmSync(grokHome, { recursive: true, force: true }) + } + }) + it('preserves user-authored hook entries in the Orca Grok config file', () => { const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 6fbbacadbc7..c88e1254feb 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -1,7 +1,7 @@ -import { homedir } from 'node:os' import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, @@ -21,33 +21,80 @@ import { writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare +// `*` is not a valid "match all" pattern and can fail to load/match, so tool +// lifecycle hooks never fire. `.*` matches every tool name (same as Command +// Code's managed hooks). +const GROK_TOOL_EVENT_MATCHER = '.*' +const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 +const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' + const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: Grok can end a turn on API error without a normal Stop; without this + // the sidebar can stick on working (same rationale as Claude StopFailure). + { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } ] as const -function getConfigPath(): string { - // Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep - // Orca's managed entries in a dedicated file so user-authored hook files stay - // untouched and project-level trust is not required for status reporting. - return join(homedir(), '.grok', 'hooks', 'orca-status.json') +/** Test seam: the matcher string written for Pre/Post tool lifecycle hooks. */ +export function getGrokToolEventMatcherForTests(): string { + return GROK_TOOL_EVENT_MATCHER } +function getConfigPath(): string { + // Why: Grok loads trusted global hook files from $GROK_HOME/hooks/*.json + // (or ~/.grok when unset). Honor GROK_HOME so install/status match the same + // home Grok and transcript lookup use; keep Orca entries in a dedicated file + // so user-authored hook files stay untouched. + return join(resolveGrokHomeDir(), 'hooks', 'orca-status.json') +} + +/** Validated guest Grok home with a login-home fallback. */ +function getRemoteGrokHome(remoteHome: string, remoteGrokHome?: string): string { + // Why: SFTP paths are always POSIX — never use host path.join here. + const home = remoteHome.replace(/\/+$/, '') || remoteHome + const candidate = remoteGrokHome?.trim() + if ( + candidate && + candidate === remoteGrokHome && + candidate.startsWith('/') && + !candidate.includes('\\') && + candidate.length <= GROK_HOME_ENVELOPE_MAX_LENGTH && + !hasControlCharacter(candidate) + ) { + return candidate.replace(/\/+$/, '') || '/' + } + return `${home}/.grok` +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( + WINDOWS_HOOK_PAYLOAD_FORM_LINE, + ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` +) + function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -71,7 +118,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - buildWindowsAgentHookPostCommand('grok'), + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + // Why: a trailing backslash escapes curl's closing argv quote on Windows, + // merging the payload option into grokHome and dropping the hook body. + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + WINDOWS_GROK_HOOK_POST_COMMAND, 'exit /b 0', '' ].join('\r\n') @@ -89,6 +141,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'grok_home=', + `if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`, + ' grok_home=$GROK_HOME', + 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl @@ -103,6 +159,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "grokHome=${grok_home}" \\', ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', 'exit 0', '' @@ -213,9 +270,15 @@ export class GrokHookService { return this.getStatus() } - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + async installRemote( + sftp: SFTPWrapper, + remoteHome: string, + remoteGrokHome?: string + ): Promise { const home = remoteHome.replace(/\/$/, '') - const remoteConfigPath = `${home}/.grok/hooks/orca-status.json` + // Why: only a guest-resolved path can describe remote Grok; never apply the + // host process's GROK_HOME to SFTP paths. + const remoteConfigPath = `${getRemoteGrokHome(home, remoteGrokHome)}/hooks/orca-status.json` const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 66d9ca47bc8..5dd3cbc8dab 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' + import { resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -38,6 +39,77 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-') + const grokSessionsDir = join(root, 'grok-sessions') + const sessionDir = join(grokSessionsDir, encodeURIComponent('/tmp/work'), 'sess-grok-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(target, '{"type":"user","content":"hi"}\n') + + const resolved = await resolveSessionFilePath('grok', 'sess-grok-1', { grokSessionsDir }) + expect(resolved).toBe(target) + }) + + it('resolves Grok chat_history by session id under a long-cwd slug group', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-long-') + const grokSessionsDir = join(root, 'grok-sessions') + const sessionDir = join(grokSessionsDir, 'slug-hash-ab12', 'sess-long-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(join(grokSessionsDir, 'slug-hash-ab12', '.cwd'), `/${'x'.repeat(400)}\n`) + await writeFile(target, '{"type":"assistant","content":"ok"}\n') + + await expect(resolveSessionFilePath('grok', 'sess-long-1', { grokSessionsDir })).resolves.toBe( + target + ) + }) + + it('ignores nested Grok decoys outside the direct group/session layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-decoy-') + const grokSessionsDir = join(root, 'grok-sessions') + const decoy = join( + grokSessionsDir, + 'group', + 'other-session', + 'nested', + 'sess-decoy', + 'chat_history.jsonl' + ) + await mkdir(dirname(decoy), { recursive: true }) + await writeFile(decoy, '{}\n') + + await expect( + resolveSessionFilePath('grok', 'sess-decoy', { grokSessionsDir }) + ).resolves.toBeNull() + }) + + it('rejects unsafe Grok session ids before filesystem discovery', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-invalid-') + const grokSessionsDir = join(root, 'grok-sessions') + await mkdir(grokSessionsDir, { recursive: true }) + + await expect( + resolveSessionFilePath('grok', '../escape', { grokSessionsDir }) + ).resolves.toBeNull() + }) + + it('resolves Grok sessions under GROK_HOME when no override is passed', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-home-') + const sessionsDir = join(root, 'sessions') + const sessionDir = join(sessionsDir, encodeURIComponent('/repo'), 'sess-env-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(target, '{}\n') + const previous = process.env.GROK_HOME + process.env.GROK_HOME = root + try { + await expect(resolveSessionFilePath('grok', 'sess-env-1')).resolves.toBe(target) + } finally { + restoreEnv('GROK_HOME', previous) + } + }) + it('matches Codex rollout files by session id suffix', async () => { const root = await makeRoot('orca-native-chat-resolve-codex-') const codexSessionsDir = join(root, 'codex-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 3b2cda9965a..adfa3856f0e 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -4,6 +4,10 @@ import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { + findGrokChatHistoryBySessionId, + resolveGrokSessionsDir +} from '../../shared/grok-session-paths' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -28,12 +32,18 @@ function codexSessionsDirs(): string[] { return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } +function grokSessionsDir(): string { + return resolveGrokSessionsDir(process.env, homedir()) +} + export type ResolveSessionFileOptions = { /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string /** Override the Codex sessions roots, searched in order (tests / isolated * scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */ codexSessionsDirs?: string[] + /** Override the Grok sessions root (`~/.grok/sessions`). */ + grokSessionsDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -76,6 +86,9 @@ export async function resolveSessionFilePath( if (agent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } + if (agent === 'grok') { + return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) + } return null } @@ -115,3 +128,13 @@ async function resolveCodexSessionFile( } return null } + +async function resolveGrokSessionFile( + sessionId: string, + sessionsDir: string +): Promise { + // Why: Native Chat runs on the main thread; use the bounded async direct-layout + // lookup instead of blocking, then repeating, a recursive full-tree scan. + const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) + return history +} diff --git a/src/main/native-chat/transcript-fallback-id.test.ts b/src/main/native-chat/transcript-fallback-id.test.ts new file mode 100644 index 00000000000..8ef46edfcf6 --- /dev/null +++ b/src/main/native-chat/transcript-fallback-id.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { transcriptFallbackId } from './transcript-fallback-id' + +describe('transcriptFallbackId', () => { + it('sorts line positions numerically when timestamps are unavailable', () => { + const ids = [10, 2, 100, 9].map((offset) => transcriptFallbackId('/chat.jsonl', offset)) + + expect(ids.sort()).toEqual([ + transcriptFallbackId('/chat.jsonl', 2), + transcriptFallbackId('/chat.jsonl', 9), + transcriptFallbackId('/chat.jsonl', 10), + transcriptFallbackId('/chat.jsonl', 100) + ]) + }) + + it('sorts later incremental byte ranges after earlier ranges', () => { + const earlier = transcriptFallbackId('/chat.jsonl', 99) + const later = transcriptFallbackId('/chat.jsonl', 100) + + expect(earlier < later).toBe(true) + }) +}) diff --git a/src/main/native-chat/transcript-fallback-id.ts b/src/main/native-chat/transcript-fallback-id.ts new file mode 100644 index 00000000000..6c993159902 --- /dev/null +++ b/src/main/native-chat/transcript-fallback-id.ts @@ -0,0 +1,10 @@ +const TRANSCRIPT_POSITION_WIDTH = 16 + +/** Stable JSONL position id shared by full reads and incremental tailing. */ +export function transcriptFallbackId(filePath: string, byteOffset: number): string { + return `${filePath}:${positionPart(byteOffset)}` +} + +function positionPart(value: number): string { + return value.toString().padStart(TRANSCRIPT_POSITION_WIDTH, '0') +} diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts new file mode 100644 index 00000000000..68dcf77701b --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -0,0 +1,55 @@ +// Claude JSONL line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks } from './transcript-record-blocks' + +export function decodeClaudeTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const role = record.type + if (role !== 'user' && role !== 'assistant') { + return null + } + const message = asRecord(record.message) + const blocks = claudeContentBlocks(message?.content) + if (blocks.length === 0) { + return null + } + const messageId = extractString(record.uuid) ?? extractString(message?.id) + return { + id: messageId ?? fallbackId, + role: claudeMessageRole(role, blocks), + blocks, + timestamp: parseTimestamp(record.timestamp), + source: 'transcript' + } +} + +// Claude marks reasoning via `thinking` content blocks; when a message is made +// up solely of reasoning, surface it as a reasoning-role message. +function claudeMessageRole( + role: 'user' | 'assistant', + blocks: NativeChatBlock[] +): NativeChatMessage['role'] { + if (role === 'user') { + const onlyToolResults = blocks.every((block) => block.type === 'tool-result') + return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' + } + return role +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts new file mode 100644 index 00000000000..64d04fa5ae3 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -0,0 +1,139 @@ +// Codex JSONL line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +export function decodeCodexTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const payload = asRecord(record.payload) + if (!payload) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const baseId = extractString(payload.id) ?? fallbackId + + if (record.type === 'response_item') { + return codexResponseItem(payload, baseId, timestamp) + } + if (record.type === 'event_msg') { + return codexEventMessage(payload, baseId, timestamp) + } + return null +} + +function codexResponseItem( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'message') { + const blocks = claudeContentBlocks(payload.content) + if (blocks.length === 0) { + return null + } + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' + return { id, role, blocks, timestamp, source: 'transcript' } + } + if (payload.type === 'reasoning') { + const text = extractString(payload.text) ?? codexSummaryText(payload.summary) + if (!text) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call' || payload.type === 'local_shell_call') { + const name = extractString(payload.name) ?? 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call_output') { + return { + id, + role: 'tool', + blocks: [codexToolResult(payload.output)], + timestamp, + source: 'transcript' + } + } + return null +} + +function codexEventMessage( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'user_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + if (payload.type === 'agent_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + return null +} + +function codexCallInput(payload: Record): unknown { + if (payload.arguments !== undefined) { + return payload.arguments + } + return payload.input ?? payload.action ?? null +} + +function codexToolResult(output: unknown): NativeChatBlock { + const record = asRecord(output) + const isError = record?.success === false || record?.is_error === true + return { + type: 'tool-result', + output: toolResultOutput(record?.content ?? record?.output ?? output), + ...(isError ? { isError: true } : {}) + } +} + +function codexSummaryText(summary: unknown): string | null { + if (!Array.isArray(summary)) { + return null + } + const parts: string[] = [] + for (const item of summary) { + const text = extractString(asRecord(item)?.text) ?? extractString(item) + if (text) { + parts.push(text) + } + } + return parts.length ? parts.join('\n') : null +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders-grok.ts b/src/main/native-chat/transcript-line-decoders-grok.ts new file mode 100644 index 00000000000..76386453970 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-grok.ts @@ -0,0 +1,247 @@ +// Grok chat_history.jsonl line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +/** + * Grok `chat_history.jsonl` rows: user/assistant/reasoning/tool records with + * Claude-like content blocks plus Grok-specific `backend_tool_call` / + * `tool_result` shapes. System prompts are skipped. + */ +export function decodeGrokTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const type = extractString(record.type) + if (!type) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const recordId = extractString(record.id) + // Why: Grok rows frequently omit timestamps and only some carry ids; prefix + // every row with its JSONL position so Native Chat preserves transcript order. + const id = recordId ? `${fallbackId}:${recordId}` : fallbackId + + if (type === 'user' || type === 'assistant') { + // Why: Grok records bootstrap context as user rows; only submitted prompts + // belong in the conversation shown to the user. + if ( + type === 'user' && + (hasNonEmptySyntheticReason(record) || isGrokBootstrapContext(record.content)) + ) { + return null + } + const rawBlocks = claudeContentBlocks(record.content) + const blocks = type === 'user' ? rawBlocks.flatMap(normalizeGrokUserQueryBlock) : rawBlocks + if (blocks.length === 0) { + // Empty assistant rows often only hold tool_calls — surface those. + const toolBlocks = grokToolCallBlocks(record.tool_calls) + if (toolBlocks.length === 0) { + return null + } + return { id, role: 'assistant', blocks: toolBlocks, timestamp, source: 'transcript' } + } + if (type === 'assistant') { + const toolBlocks = grokToolCallBlocks(record.tool_calls) + return { + id, + role: 'assistant', + blocks: [...blocks, ...toolBlocks], + timestamp, + source: 'transcript' + } + } + return { id, role: 'user', blocks, timestamp, source: 'transcript' } + } + + if (type === 'reasoning') { + const text = + extractString(record.text) ?? + grokSummaryText(record.summary) ?? + extractString(asRecord(record.content)?.text) + if (!text?.trim()) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + + if (type === 'backend_tool_call' || type === 'tool_call') { + const name = + extractString(asRecord(record.kind)?.tool_type) ?? + extractString(record.name) ?? + extractString(record.tool) ?? + 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: record.kind ?? record.arguments ?? record.input }], + timestamp, + source: 'transcript' + } + } + + if (type === 'tool_result') { + const output = toolResultOutput(record.content ?? record.output ?? record.result) + return { + id, + role: 'tool', + blocks: [ + { + type: 'tool-result', + output, + ...(record.is_error === true || record.isError === true ? { isError: true } : {}) + } + ], + timestamp, + source: 'transcript' + } + } + + return null +} + +function grokToolCallBlocks(value: unknown): NativeChatBlock[] { + if (!Array.isArray(value)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of value) { + const record = asRecord(item) + if (!record) { + continue + } + const name = extractString(record.name) ?? extractString(record.tool) ?? 'tool' + let input: unknown = record.arguments ?? record.input ?? record.args + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + // keep string + } + } + blocks.push({ type: 'tool-call', name, input }) + } + return blocks +} + +function grokSummaryText(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + const record = asRecord(item) + const text = extractString(record?.text) ?? extractString(record?.summary_text) + if (text) { + parts.push(text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +function hasNonEmptySyntheticReason(record: Record): boolean { + return typeof record.synthetic_reason === 'string' && record.synthetic_reason.trim().length > 0 +} + +function isGrokBootstrapContext(content: unknown): boolean { + const text = standaloneTextContent(content) + if (!text) { + return false + } + const normalized = text.trim().toLowerCase() + if (!normalized.startsWith('')) { + return false + } + const userInfoEnd = normalized.indexOf('') + if (userInfoEnd === -1) { + return false + } + const remainder = normalized.slice(userInfoEnd + ''.length).trim() + // Why: Grok 0.2.93 appends its git snapshot to the user-info bootstrap row; + // reject only that known envelope so real prompts mentioning either tag survive. + return ( + remainder.length === 0 || + (remainder.startsWith('') && remainder.endsWith('')) + ) +} + +function standaloneTextContent(content: unknown): string | null { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content) || content.length !== 1) { + return null + } + const block = asRecord(content[0]) + return block?.type === 'text' ? (extractString(block.text) ?? null) : null +} + +function normalizeGrokUserQueryBlock(block: NativeChatBlock): NativeChatBlock[] { + if (block.type !== 'text') { + return [block] + } + const stripped = stripGrokUserQueryEnvelope(block.text) + if (!stripped.trim()) { + return [] + } + const pastedImage = splitGrokPastedImageQuery(stripped) + if (!pastedImage) { + return [stripped === block.text ? block : { type: 'text', text: stripped }] + } + return [ + { type: 'image-ref', path: pastedImage.path }, + ...(pastedImage.query ? [{ type: 'text' as const, text: pastedImage.query }] : []) + ] +} + +function splitGrokPastedImageQuery(text: string): { path: string; query: string } | null { + // Why: Grok 0.2.93 persists clipboard images as an absolute temp path directly + // concatenated with the prompt; recover Orca's attachment without exposing it. + const match = text.match( + /^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?\.png)([\s\S]*)$/i + ) + if (!match?.[1]) { + return null + } + return { path: match[1], query: (match[2] ?? '').trim() } +} + +function stripGrokUserQueryEnvelope(text: string): string { + const opener = '' + const closer = '' + const lower = text.toLowerCase() + const start = lower.indexOf(opener) + if (start === -1) { + return text + } + const bodyStart = start + opener.length + const end = lower.indexOf(closer, bodyStart) + if (end === -1) { + return text.slice(bodyStart).trim() + } + return text.slice(bodyStart, end).trim() +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.grok.test.ts b/src/main/native-chat/transcript-line-decoders.grok.test.ts new file mode 100644 index 00000000000..13295dcfcdd --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.grok.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import { decodeGrokTranscriptLine } from './transcript-line-decoders' + +describe('decodeGrokTranscriptLine', () => { + it('decodes user text and strips wrappers', () => { + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: '\nFix the bug\n' }], + timestamp: '2026-06-18T00:00:00.000Z' + }) + expect(decodeGrokTranscriptLine(line, 'fb-1')).toEqual({ + id: 'fb-1', + role: 'user', + blocks: [{ type: 'text', text: 'Fix the bug' }], + timestamp: Date.parse('2026-06-18T00:00:00.000Z'), + source: 'transcript' + }) + }) + + it('decodes assistant tool calls on empty content rows', () => { + const line = JSON.stringify({ + type: 'assistant', + content: '', + tool_calls: [{ id: 'c1', name: 'grep', arguments: '{"pattern":"foo"}' }], + id: 'asst-1' + }) + expect(decodeGrokTranscriptLine(line, 'fb-2')).toEqual({ + id: 'fb-2:asst-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'grep', input: { pattern: 'foo' } }], + timestamp: null, + source: 'transcript' + }) + }) + + it.each([ + 'C:\\Users\\me\\AppData\\Local\\Temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + 'C:\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '\\\\server\\temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png' + ])('restores a pasted image and prompt from Grok transcript text for %s', (imagePath) => { + const line = JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: `\n${imagePath}Describe this image\n` + } + ] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image')).toMatchObject({ + role: 'user', + blocks: [ + { type: 'image-ref', path: imagePath }, + { type: 'text', text: 'Describe this image' } + ] + }) + }) + + it('restores an attachment-only pasted image from Grok transcript text', () => { + const imagePath = '/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png' + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: `${imagePath}` }] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image-only')).toMatchObject({ + role: 'user', + blocks: [{ type: 'image-ref', path: imagePath }] + }) + }) + + it('preserves ordinary prompts that mention pasted-image filenames', () => { + const text = 'Explain what an orca-paste-123-example.png file is' + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: `${text}` }] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image-mention')).toMatchObject({ + role: 'user', + blocks: [{ type: 'text', text }] + }) + }) + + it('preserves assistant text containing a literal user_query tag', () => { + const text = 'Before quoted example after' + const line = JSON.stringify({ type: 'assistant', content: text }) + + expect(decodeGrokTranscriptLine(line, 'fb-assistant-tag')).toMatchObject({ + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + }) + + it.each(['project_instructions', 'system_reminder'])( + 'skips Grok synthetic user rows with reason %s', + (syntheticReason) => { + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: 'Internal context' }], + synthetic_reason: syntheticReason + }) + + expect(decodeGrokTranscriptLine(line, `fb-${syntheticReason}`)).toBeNull() + } + ) + + it.each([ + 'bootstrap context', + [{ type: 'text', text: 'bootstrap context' }], + [ + { + type: 'text', + text: [ + 'Runtime context', + 'Working tree snapshot' + ].join('\n\n') + } + ] + ])('skips a standalone Grok user_info bootstrap row', (content) => { + expect( + decodeGrokTranscriptLine(JSON.stringify({ type: 'user', content }), 'fb-user-info') + ).toBeNull() + }) + + it('keeps a real query that discusses the user_info tag', () => { + const line = JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: 'Explain the tag' + } + ] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-user-info-query')).toMatchObject({ + role: 'user', + blocks: [{ type: 'text', text: 'Explain the tag' }] + }) + }) + + it('filters Grok bootstrap rows from a real-schema transcript prefix', () => { + const rows = [ + { type: 'system', content: 'System context' }, + { + type: 'user', + content: [{ type: 'text', text: 'Runtime context' }] + }, + { + type: 'user', + content: [{ type: 'text', text: 'Project context' }], + synthetic_reason: 'project_instructions' + }, + { + type: 'user', + content: [{ type: 'text', text: 'Reminder context' }], + synthetic_reason: 'system_reminder' + }, + { + type: 'user', + content: [{ type: 'text', text: 'Visible prompt' }] + }, + { type: 'assistant', content: 'Visible answer' } + ] + + const messages = rows + .map((row, index) => decodeGrokTranscriptLine(JSON.stringify(row), `fb-real-schema-${index}`)) + .filter((message) => message !== null) + + expect(messages).toMatchObject([ + { role: 'user', blocks: [{ type: 'text', text: 'Visible prompt' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Visible answer' }] } + ]) + }) + + it('decodes reasoning summaries', () => { + const line = JSON.stringify({ + type: 'reasoning', + id: 'rs-1', + summary: [{ type: 'summary_text', text: 'Planning the change' }] + }) + expect(decodeGrokTranscriptLine(line, 'fb-3')).toMatchObject({ + id: 'fb-3:rs-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Planning the change' }], + source: 'transcript' + }) + }) + + it('skips system prompts', () => { + const line = JSON.stringify({ type: 'system', content: 'You are Grok' }) + expect(decodeGrokTranscriptLine(line, 'fb-4')).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 1a428cbb98a..67b1aaf76aa 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -5,181 +5,10 @@ // or null (unknown/empty records are skipped, never thrown — plan KTD risk: // schema drift). `fallbackId` is used only when the record carries no intrinsic // id; the caller supplies a value unique per line. +// +// Why: agent-specific decoders live in dedicated modules so this barrel stays +// under the max-lines limit while callers keep a single import path. -import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' -import { - asRecord, - extractString, - parseJsonObject, - timestampMs -} from '../ai-vault/session-scanner-values' -import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' - -export function decodeClaudeTranscriptLine( - line: string, - fallbackId: string -): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { - return null - } - const role = record.type - if (role !== 'user' && role !== 'assistant') { - return null - } - const message = asRecord(record.message) - const blocks = claudeContentBlocks(message?.content) - if (blocks.length === 0) { - return null - } - const messageId = extractString(record.uuid) ?? extractString(message?.id) - return { - id: messageId ?? fallbackId, - role: claudeMessageRole(role, blocks), - blocks, - timestamp: parseTimestamp(record.timestamp), - source: 'transcript' - } -} - -// Claude marks reasoning via `thinking` content blocks; when a message is made -// up solely of reasoning, surface it as a reasoning-role message. -function claudeMessageRole( - role: 'user' | 'assistant', - blocks: NativeChatBlock[] -): NativeChatMessage['role'] { - if (role === 'user') { - const onlyToolResults = blocks.every((block) => block.type === 'tool-result') - return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' - } - return role -} - -export function decodeCodexTranscriptLine( - line: string, - fallbackId: string -): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { - return null - } - const payload = asRecord(record.payload) - if (!payload) { - return null - } - const timestamp = parseTimestamp(record.timestamp) - const baseId = extractString(payload.id) ?? fallbackId - - if (record.type === 'response_item') { - return codexResponseItem(payload, baseId, timestamp) - } - if (record.type === 'event_msg') { - return codexEventMessage(payload, baseId, timestamp) - } - return null -} - -function codexResponseItem( - payload: Record, - id: string, - timestamp: number | null -): NativeChatMessage | null { - if (payload.type === 'message') { - const blocks = claudeContentBlocks(payload.content) - if (blocks.length === 0) { - return null - } - const role = - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' - return { id, role, blocks, timestamp, source: 'transcript' } - } - if (payload.type === 'reasoning') { - const text = extractString(payload.text) ?? codexSummaryText(payload.summary) - if (!text) { - return null - } - return { - id, - role: 'reasoning', - blocks: [{ type: 'text', text }], - timestamp, - source: 'transcript' - } - } - if (payload.type === 'function_call' || payload.type === 'local_shell_call') { - const name = extractString(payload.name) ?? 'tool' - return { - id, - role: 'assistant', - blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], - timestamp, - source: 'transcript' - } - } - if (payload.type === 'function_call_output') { - return { - id, - role: 'tool', - blocks: [codexToolResult(payload.output)], - timestamp, - source: 'transcript' - } - } - return null -} - -function codexEventMessage( - payload: Record, - id: string, - timestamp: number | null -): NativeChatMessage | null { - if (payload.type === 'user_message') { - const text = extractString(payload.message) - return text - ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } - : null - } - if (payload.type === 'agent_message') { - const text = extractString(payload.message) - return text - ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } - : null - } - return null -} - -function codexCallInput(payload: Record): unknown { - if (payload.arguments !== undefined) { - return payload.arguments - } - return payload.input ?? payload.action ?? null -} - -function codexToolResult(output: unknown): NativeChatBlock { - const record = asRecord(output) - const isError = record?.success === false || record?.is_error === true - return { - type: 'tool-result', - output: toolResultOutput(record?.content ?? record?.output ?? output), - ...(isError ? { isError: true } : {}) - } -} - -function codexSummaryText(summary: unknown): string | null { - if (!Array.isArray(summary)) { - return null - } - const parts: string[] = [] - for (const item of summary) { - const text = extractString(asRecord(item)?.text) ?? extractString(item) - if (text) { - parts.push(text) - } - } - return parts.length ? parts.join('\n') : null -} - -function parseTimestamp(value: unknown): number | null { - const parsed = timestampMs(value) - return Number.isFinite(parsed) ? parsed : null -} +export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' +export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index c0b5f64bb68..1dbbd7dd82a 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,9 +1,13 @@ import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' -import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { decodeTranscriptStream } from './transcript-stream-lines' export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } @@ -17,8 +21,7 @@ export type ReadTranscriptOptions = ResolveSessionFileOptions & { * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies * NO message cap. Unknown record types are skipped rather than throwing, so a * single malformed/unrecognized line cannot fail the whole read. The per-line - * record→message mapping is shared with the live tailer (transcript-watch.ts) - * via transcript-line-decoders.ts. + * record-to-message mapping is shared with the live tailer. */ export async function readNativeChatTranscript( agent: AgentType, @@ -36,6 +39,9 @@ export async function readNativeChatTranscript( if (agent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } + if (agent === 'grok') { + return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } + } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { return { error: errorMessage(err) } @@ -46,21 +52,7 @@ async function readTranscript( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null ): Promise { - const reader = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - const messages: NativeChatMessage[] = [] - let index = 0 - for await (const line of reader) { - // Why: fallback id embeds start offset 0 so it matches the live tailer's id - // for the same record (the tailer's first drain reads from offset 0 too). - // Records that re-emit then collapse by id in the assembler — no dup, no drop. - const message = decode(line, `${filePath}:0:${index}`) - if (message) { - messages.push(message) - } - index++ - } + const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true) return messages } diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts new file mode 100644 index 00000000000..2bfa6f6f9d2 --- /dev/null +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -0,0 +1,49 @@ +import { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { decodeTranscriptStream } from './transcript-stream-lines' + +const decode = (line: string, id: string) => ({ + id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: line }], + timestamp: null, + source: 'transcript' as const +}) + +describe('decodeTranscriptStream', () => { + it('uses identical absolute byte ids for full and incremental reads', async () => { + const prefix = '{"first":"é"}\r\n' + const appended = '{"second":true}\n' + const full = await decodeTranscriptStream( + Readable.from([prefix + appended]), + '/chat.jsonl', + 0, + decode, + true + ) + const incremental = await decodeTranscriptStream( + Readable.from([appended]), + '/chat.jsonl', + Buffer.byteLength(prefix, 'utf8'), + decode, + false + ) + + expect(incremental.messages[0]?.id).toBe(full.messages[1]?.id) + }) + + it('does not consume a partial trailing JSONL record', async () => { + const complete = '{"first":true}\n' + const partial = '{"second"' + const result = await decodeTranscriptStream( + Readable.from([complete + partial]), + '/chat.jsonl', + 0, + decode, + false + ) + + expect(result.messages).toHaveLength(1) + expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) + }) +}) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts new file mode 100644 index 00000000000..a764941ad30 --- /dev/null +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -0,0 +1,47 @@ +import type { Readable } from 'node:stream' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' + +type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export async function decodeTranscriptStream( + stream: Readable, + filePath: string, + start: number, + decode: TranscriptDecoder, + includeTrailingLine: boolean +): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { + const messages: NativeChatMessage[] = [] + let pending = '' + let consumedBytes = 0 + + for await (const chunk of stream) { + pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + let newlineIndex = pending.indexOf('\n') + while (newlineIndex !== -1) { + const segment = pending.slice(0, newlineIndex + 1) + decodeLine(segment.slice(0, -1), consumedBytes) + consumedBytes += Buffer.byteLength(segment, 'utf8') + pending = pending.slice(newlineIndex + 1) + newlineIndex = pending.indexOf('\n') + } + } + + if (includeTrailingLine && pending.length > 0) { + decodeLine(pending, consumedBytes) + consumedBytes += Buffer.byteLength(pending, 'utf8') + } + + return { messages, consumedBytes } + + function decodeLine(rawLine: string, relativeOffset: number): void { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) + if (message) { + messages.push(message) + } + } +} diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 919cac24e10..4bbd934f0eb 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -10,10 +10,14 @@ import { watch, type FSWatcher } from 'node:fs' import { open, stat } from 'node:fs/promises' -import type { Readable } from 'node:stream' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' -import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { decodeTranscriptStream } from './transcript-stream-lines' export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { agent: AgentType @@ -57,6 +61,9 @@ function lineDecoderForAgent( if (agent === 'codex') { return decodeCodexTranscriptLine } + if (agent === 'grok') { + return decodeGrokTranscriptLine + } return null } @@ -94,53 +101,19 @@ async function readAppendedMessages( end: end - 1, autoClose: false }) - const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode) + const { messages, consumedBytes } = await decodeTranscriptStream( + stream, + filePath, + start, + decode, + false + ) return { messages, consumedTo: start + consumedBytes } } finally { await handle.close() } } -async function decodeStreamLines( - stream: Readable, - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { - const text = await readStreamText(stream) - const completeEnd = text.lastIndexOf('\n') - if (completeEnd === -1) { - return { messages: [], consumedBytes: 0 } - } - - // Why: transcript writers can flush mid-record. Only advance through - // newline-terminated JSONL so invalid partial JSON is retried on the next - // append instead of being lost forever. - const completeText = text.slice(0, completeEnd + 1) - const lines = completeText.split('\n') - const messages: NativeChatMessage[] = [] - for (const [index, line] of lines.entries()) { - if (!line) { - continue - } - // Fallback id embeds the byte offset so ids stay stable+unique across - // appends even when a record carries no intrinsic id. - const message = decode(line, `${filePath}:${start}:${index}`) - if (message) { - messages.push(message) - } - } - return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') } -} - -async function readStreamText(stream: Readable): Promise { - const chunks: string[] = [] - for await (const chunk of stream) { - chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - } - return chunks.join('') -} - /** * Subscribe to live appends on an agent's transcript file. Returns an * unsubscribe fn that tears the watcher down completely. diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 27a279cdd55..ca0a9fd8261 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -138,10 +138,41 @@ describe('SshPtyProvider', () => { cols: 120, rows: 40, cwd: undefined, - env: {} + env: {}, + envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV] }) }) + it('preserves explicit TERM and forwards final env deletions to the relay', async () => { + mux.request.mockResolvedValue({ id: 'pty-env-precedence' }) + const envToDelete = ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + + await provider.spawn({ + cols: 120, + rows: 40, + env: { + TERM: 'screen-256color', + TERM_PROGRAM: 'stale-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution' + }, + envToDelete + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { + TERM: 'screen-256color', + [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' + }, + envToDelete + }) + const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn') + expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM') + expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_ATTRIBUTION_SHIM_DIR') + }) + it('forwards provider command delivery to the relay', async () => { mux.request.mockResolvedValue({ id: 'pty-provider-command' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index be84c2a6267..e386e89dbba 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -149,6 +149,7 @@ export class SshPtyProvider implements IPtyProvider { rows: opts.rows, cwd: opts.cwd, env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), + ...(opts.envToDelete?.length ? { envToDelete: opts.envToDelete } : {}), // Why: the relay's plugin-overlay env augmenter needs to know which // Pi-compatible agent is being launched, while commandDelivery tells it // whether to submit the command itself for runtime-owned background PTYs. @@ -179,27 +180,28 @@ export class SshPtyProvider implements IPtyProvider { envToDelete?: readonly string[] ): Record { const merged = { ...env } + if (this.remoteCliBridgeEnv) { + const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' + const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null + if (pathKey) { + const pathValue = merged[pathKey] ?? '' + merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) + ? pathValue + : pathValue + ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` + : this.remoteCliBridgeEnv.binDir + } + merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir + merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir + merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath + merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath + } + // Why: match local/daemon precedence—managed defaults and augmentations + // cannot resurrect values the caller explicitly removed. for (const key of envToDelete ?? []) { delete merged[key] } seedPowerlevel10kWizardEnv(merged, { envToDelete }) - if (!this.remoteCliBridgeEnv) { - return merged - } - const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' - const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null - if (pathKey) { - const pathValue = merged[pathKey] ?? '' - merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) - ? pathValue - : pathValue - ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` - : this.remoteCliBridgeEnv.binDir - } - merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir - merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir - merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath - merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath return merged } diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 508e0569bec..10ba629ff34 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -1,10 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' import { join } from 'node:path' +import { resolveGrokHomeDir } from '../../shared/grok-session-paths' // Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses. export function getGrokHome(): string { - return process.env.GROK_HOME?.trim() || join(homedir(), '.grok') + return resolveGrokHomeDir() } export function getGrokAuthPath(): string { diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 614c121d56a..aebfd234b5c 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -172,6 +172,39 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) + it.each([ + ['/bin/bash', "'/bin/bash' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/usr/bin/fish', "'/usr/bin/fish' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/bin/tcsh', "'/bin/tcsh' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/bin/sh', "'/bin/sh' -c 'printenv GROK_HOME | head -c 4097'"] + ])('uses a shell-independent GROK_HOME probe with login shell %s', async (shell, command) => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => + method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true } + ) + vi.mocked(execCommand).mockResolvedValueOnce(`${shell}\n`).mockResolvedValueOnce('/srv/grok\n') + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(execCommand).toHaveBeenNthCalledWith( + 1, + mockConn, + "printenv SHELL || printf '/bin/sh\\n'", + { timeoutMs: 8_000 } + ) + expect(execCommand).toHaveBeenNthCalledWith(2, mockConn, command, { + wrapCommand: false, + timeoutMs: 8_000 + }) + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/srv/grok' + }) + }) + it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' muxRequestMock.mockImplementation(async (method: string) => { @@ -181,6 +214,9 @@ describe('SshRelaySession', () => { return { ok: true } }) const sftp = { end: vi.fn() } + vi.mocked(execCommand) + .mockResolvedValueOnce('/bin/bash\n') + .mockResolvedValueOnce('/srv/grok profile\n') const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) @@ -199,7 +235,9 @@ describe('SshRelaySession', () => { ompExtensionSource: expect.stringContaining('/hook/omp') }) expect(mockConn.sftp).toHaveBeenCalledTimes(1) - expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/srv/grok profile' + }) expect(sftp.end).toHaveBeenCalledTimes(1) expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan( muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex] @@ -209,6 +247,26 @@ describe('SshRelaySession', () => { ) }) + it('falls back to login-home Grok config when the remote env probe is invalid', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => + method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true } + ) + vi.mocked(execCommand) + .mockResolvedValueOnce('/bin/bash\n') + .mockResolvedValueOnce('relative/grok-home\n') + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/home/orca/.grok' + }) + }) + it('does not run POSIX managed hook installers on Windows remotes', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' const { mockStore, mockPortForward, getMainWindow } = createMockDeps() diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 4b29d7f7ea3..b2564259ffd 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -70,6 +70,7 @@ import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { shellEscape } from './ssh-connection-utils' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -85,6 +86,69 @@ type RemoteCliBridgeEnv = { type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } +const REMOTE_GROK_HOME_MAX_LENGTH = 4096 +const REMOTE_GROK_HOME_PROBE_TIMEOUT_MS = 8_000 + +function defaultRemoteGrokHome(remoteHome: string): string { + const home = remoteHome.replace(/\/+$/, '') || remoteHome + return `${home}/.grok` +} + +function normalizeRemoteGrokHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > REMOTE_GROK_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +function loginShellCommand(shell: string, command: string): string { + const shellName = shell.split('/').at(-1) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` +} + +async function resolveRemoteGrokHome( + connection: SshConnection, + remoteHome: string +): Promise { + const fallback = defaultRemoteGrokHome(remoteHome) + try { + // Why: remote PTYs start login shells, so probe the same profile-derived + // environment instead of the relay service or local Electron environment. + const shellOutput = await execCommand(connection, "printenv SHELL || printf '/bin/sh\\n'", { + timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS + }) + const shell = shellOutput.trim().split(/\r?\n/, 1)[0] + if (!shell?.startsWith('/') || hasControlCharacter(shell)) { + return fallback + } + // Why: this runs inside the user's actual login shell, which may be fish or + // tcsh. External commands avoid shell-specific variable/conditional syntax. + const probe = `printenv GROK_HOME | head -c ${REMOTE_GROK_HOME_MAX_LENGTH + 1}` + const output = await execCommand(connection, loginShellCommand(shell, probe), { + wrapCommand: false, + timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS + }) + return normalizeRemoteGrokHome(output.split(/\r?\n/, 1)[0] ?? '') ?? fallback + } catch { + return fallback + } +} + function expectedIdentityForLease(lease: { tabId?: string leafId?: string @@ -673,8 +737,10 @@ export class SshRelaySession { let sftp: Awaited> | null = null try { - sftp = await this.requireReadyConnection().sftp() - await installRemoteManagedAgentHooks(sftp, remoteHome) + const connection = this.requireReadyConnection() + const remoteGrokHome = await resolveRemoteGrokHome(connection, remoteHome) + sftp = await connection.sftp() + await installRemoteManagedAgentHooks(sftp, remoteHome, { grokHomeDir: remoteGrokHome }) } catch (error) { console.warn( `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 442052c1e96..4edb3dde409 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' +import * as agentHookListener from '../shared/agent-hook-listener' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) @@ -257,6 +258,8 @@ describe('RelayAgentHookServer', () => { }) expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + // Let the first 50ms retry miss so continuation across SessionEnd is proven. + await new Promise((resolve) => setTimeout(resolve, 70)) writeFileSync( transcriptPath, `${JSON.stringify({ @@ -378,4 +381,111 @@ describe('RelayAgentHookServer', () => { server.stop() } }, 30_000) + + it('forwards a Grok result when discovery finishes after the old retry window', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf534' + const cwd = join(dir, 'workspace') + const sessionDir = join(dir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + const history = join(sessionDir, 'chat_history.jsonl') + writeFileSync(history, '') + vi.stubEnv('HOME', dir) + vi.stubEnv('USERPROFILE', dir) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload + }) + }) + + await post({ hookEventName: 'UserPromptSubmit', prompt: 'delayed relay result' }) + await post({ hookEventName: 'Stop', sessionId, cwd }) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + history, + `${JSON.stringify({ type: 'assistant', content: 'Relay found after discovery.' })}\n` + ) + releaseDiscovery() + + await vi.waitFor(() => { + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( + 'Relay found after discovery.' + ) + }) + } finally { + server.stop() + vi.unstubAllEnvs() + } + }) + + it('does not forward an old result over a newer same-text Grok turn', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload + }) + }) + + await post({ hookEventName: 'UserPromptSubmit', prompt: 'repeat me' }) + await post({ + hookEventName: 'Stop', + sessionId: '019e37f4-5135-7b63-a4ab-6d13aa6bf535', + cwd: join(dir, 'workspace') + }) + await post({ hookEventName: 'UserPromptSubmit', prompt: 'repeat me' }) + const forwardsBeforeDiscovery = forward.mock.calls.length + + releaseDiscovery() + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(forward).toHaveBeenCalledTimes(forwardsBeforeDiscovery) + expect(forward.mock.calls.at(-1)?.[0].payload).toMatchObject({ + state: 'working', + prompt: 'repeat me', + agentType: 'grok' + }) + } finally { + server.stop() + } + }) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 741a2a5ca01..f5200a2b178 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -25,6 +25,7 @@ import { hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, + preparePendingGrokResultDiscovery, readRequestBody, resolveHookSource, writeEndpointFile, @@ -406,7 +407,8 @@ export class RelayAgentHookServer { original: AgentHookEventPayload, env?: string, version?: string, - attempt = 1 + attempt = 1, + discoveryReady = false ): void { if ( original.payload.lastAssistantMessage || @@ -416,26 +418,37 @@ export class RelayAgentHookServer { return } this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: remote slug-group discovery can outlive the bounded transcript- + // flush timers, so completion itself drives the first retry. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, env, version, 1, true) + } + }) + .catch((err) => { + process.stderr.write( + `[relay-hook-server] Grok result discovery failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + }) + return + } + } const timer = setTimeout(() => { try { this.assistantMessageRetryTimers.delete(original.paneKey) - const current = this.state.lastStatusByPaneKey.get(original.paneKey) - if ( - !current || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const event = normalizeHookPayload(this.state, source, body, this.env) - if (!event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, env, version, attempt + 1) - return - } - // Why: the relay runs on SSH targets too; retry from a timer so a delayed - // transcript/chat-history write does not block the remote hook server. - this.applyEvent(event, source, env, version) + this.applyAssistantMessageRetry( + source, + body, + original, + env, + version, + attempt + 1, + discoveryReady + ) } catch (err) { process.stderr.write( `[relay-hook-server] assistant message retry failed: ${err instanceof Error ? err.message : String(err)}\n` @@ -448,6 +461,41 @@ export class RelayAgentHookServer { } } + private applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: AgentHookEventPayload, + env: string | undefined, + version: string | undefined, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (!event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry( + source, + body, + original, + env, + version, + nextAttempt, + requireExactOriginal + ) + return + } + this.applyEvent(event, source, env, version) + } + private bodyEnv(body: unknown): string | undefined { if (typeof body !== 'object' || body === null) { return undefined diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 7d5198e87a9..332828408bd 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1290,11 +1290,78 @@ describe('PtyHandler', () => { } } - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode') expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') }) + it('applies identity defaults, then deletions, while preserving explicit TERM', async () => { + handler.addEnvAugmenter(() => ({ + TERM: 'augmenter-term', + TERM_PROGRAM: 'augmenter-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/augmenter-attribution' + })) + + await dispatcher.callRequest('pty.spawn', { + env: { + TERM: 'screen-256color', + TERM_PROGRAM: 'renderer-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/renderer-attribution' + }, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('screen-256color') + expect(spawnEnv.env.TERM).toBe('screen-256color') + expect(spawnEnv.env.COLORTERM).toBe('truecolor') + expect(spawnEnv.env.FORCE_HYPERLINK).toBe('1') + expect(spawnEnv.env.TERM_PROGRAM).toBeUndefined() + expect(spawnEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('replaces an ambient TERM=dumb when no explicit TERM is supplied', async () => { + const previousTerm = process.env.TERM + process.env.TERM = 'dumb' + try { + await dispatcher.callRequest('pty.spawn', {}) + } finally { + if (previousTerm === undefined) { + delete process.env.TERM + } else { + process.env.TERM = previousTerm + } + } + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') + expect(spawnEnv.env.TERM).toBe('xterm-256color') + expect(spawnEnv.env.TERM_PROGRAM).toBe('Orca') + }) + + it('uses the safe terminal default when TERM is deleted without a custom value', async () => { + await dispatcher.callRequest('pty.spawn', { + envToDelete: ['TERM'] + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') + expect(spawnEnv.env.TERM).toBe('xterm-256color') + }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { handler.addEnvAugmenter((ctx) => ({ SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' @@ -1402,6 +1469,172 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_WORKTREE_ID).toBe('wt-5') expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') + expect(callArgs.env.TERM).toBe('xterm-256color') + expect(callArgs.env.TERM_PROGRAM).toBe('Orca') + }) + + it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => { + await dispatcher.callRequest('pty.spawn', { + env: { TERM: '' }, + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR', '', 42] + }) + + const initialEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(initialEnv.name).toBe('xterm-256color') + expect(initialEnv.env.TERM).toBe('xterm-256color') + + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + const [serialized] = JSON.parse(state) as { + explicitTerm?: string + envToDelete?: string[] + }[] + expect(serialized.explicitTerm).toBeUndefined() + expect(serialized.envToDelete).toEqual(['ORCA_ATTRIBUTION_SHIM_DIR']) + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/revived-attribution' + })) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(revivedEnv.name).toBe('xterm-256color') + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('drops legacy empty explicit TERM metadata after revive', async () => { + const state = JSON.stringify([ + { + id: 'pty-8', + pid: process.pid, + cols: 80, + rows: 24, + cwd: process.cwd(), + explicitTerm: '', + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + } + ]) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/legacy-empty-attribution' + })) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(revivedEnv.name).toBe('xterm-256color') + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + + const serializedState = (await dispatcher.callRequest('pty.serialize', { + ids: ['pty-8'] + })) as string + const [serialized] = JSON.parse(serializedState) as { + explicitTerm?: string + envToDelete?: string[] + }[] + expect(serialized.explicitTerm).toBeUndefined() + expect(serialized.envToDelete).toEqual(['ORCA_ATTRIBUTION_SHIM_DIR']) + }) + + it('preserves explicit TERM and env deletions through repeated revive cycles', async () => { + await dispatcher.callRequest('pty.spawn', { + env: { TERM: 'screen-256color' }, + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + }) + let state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/first-revive' + })) + await dispatcher.callRequest('pty.revive', { state }) + + const firstRevivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(firstRevivedEnv.name).toBe('screen-256color') + expect(firstRevivedEnv.env.TERM).toBe('screen-256color') + expect(firstRevivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + expect(JSON.parse(state)).toMatchObject([ + { + explicitTerm: 'screen-256color', + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + } + ]) + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/second-revive' + })) + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const secondRevivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(secondRevivedEnv.name).toBe('screen-256color') + expect(secondRevivedEnv.env.TERM).toBe('screen-256color') + expect(secondRevivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('revives legacy serialized entries with default TERM and no env deletions', async () => { + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/legacy-attribution' + })) + const state = JSON.stringify([ + { + id: 'pty-7', + pid: process.pid, + cols: 80, + rows: 24, + cwd: process.cwd() + } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBe('/tmp/legacy-attribution') }) it('revive preserves attach identity metadata without exporting hook identity env', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 3a812415909..965d8cbdc30 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -68,6 +68,8 @@ type ManagedPty = { attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string + explicitTerm?: string + envToDelete: string[] startupCommand?: ManagedStartupCommand } @@ -180,6 +182,16 @@ type SerializedPtyEntry = { attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string + explicitTerm?: string + envToDelete?: string[] +} + +function sanitizeEnvToDelete(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((key): key is string => typeof key === 'string' && key.length > 0) + .slice(0, 1_024) + : [] } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -283,9 +295,19 @@ export class PtyHandler { * otherwise agent-status over SSH silently breaks on every revive. */ private buildSpawnEnv( rendererEnv: Record | undefined, - ctx: { id: string; paneKey?: string; shell: string; command?: string } + ctx: { id: string; paneKey?: string; shell: string; command?: string }, + envToDelete: readonly string[] = [] ): Record { - const baseEnv = { ...process.env, ...rendererEnv } as Record + const baseEnv = { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + TERM_PROGRAM_VERSION: + rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev', + FORCE_HYPERLINK: '1', + ...rendererEnv + } as Record const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { @@ -296,7 +318,25 @@ export class PtyHandler { ) } } - return { ...baseEnv, ...augmented } + const result = { ...baseEnv, ...augmented } + // Why: match local/daemon precedence so relay defaults and augmenters + // cannot resurrect attribution or identity values explicitly removed. + for (const key of envToDelete) { + delete result[key] + } + if ( + !envToDelete.includes('TERM') && + rendererEnv && + Object.prototype.hasOwnProperty.call(rendererEnv, 'TERM') + ) { + result.TERM = rendererEnv.TERM + } + // Why: node-pty treats missing/empty TERM as its own platform-specific + // default. Normalize here so POSIX and Windows relay children agree. + if (!result.TERM) { + result.TERM = 'xterm-256color' + } + return result } private clearStartupCommandTimer(managed: ManagedPty): void { @@ -562,6 +602,15 @@ export class PtyHandler { const rows = (params.rows as number) || 24 const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined + const envToDelete = sanitizeEnvToDelete(params.envToDelete) + const explicitTerm = + !envToDelete.includes('TERM') && + env && + Object.prototype.hasOwnProperty.call(env, 'TERM') && + typeof env.TERM === 'string' && + env.TERM.length > 0 + ? env.TERM + : undefined const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' const resolvedShellOverride = resolvePtyShellOverride(shellOverride) @@ -585,7 +634,7 @@ export class PtyHandler { typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined - const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) + const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) const shouldEmitShellReadyMarker = launchCommandHint !== undefined && @@ -607,7 +656,9 @@ export class PtyHandler { // When overlays are injected, the launch wrapper keeps those paths after // user startup files re-export their defaults. const term = pty.spawn(shell, shellLaunch.args, { - name: 'xterm-256color', + // Why: node-pty overwrites env.TERM with `name`; keep caller-selected + // terminal identities instead of losing them at the final spawn boundary. + name: spawnEnv.TERM ?? 'xterm-256color', cols, rows, cwd, @@ -635,6 +686,8 @@ export class PtyHandler { tabId, ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), worktreeId, + ...(explicitTerm !== undefined ? { explicitTerm } : {}), + envToDelete, ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { @@ -935,6 +988,8 @@ export class PtyHandler { tabId: managed.tabId, attachIdentity: managed.attachIdentity, worktreeId: managed.worktreeId, + ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), + envToDelete: managed.envToDelete, ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -976,6 +1031,16 @@ export class PtyHandler { if (entry.terminalHandle) { revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle } + const explicitTerm = + typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0 + ? entry.explicitTerm + : undefined + if (explicitTerm !== undefined) { + revivedEnv.TERM = explicitTerm + } + // Why: serialized state can come from an older or untrusted client, so + // revive reapplies the same bounds as a fresh spawn before retaining it. + const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -984,14 +1049,19 @@ export class PtyHandler { // undefined` for revived PTYs and prepares the Pi default plus OMP's // typed-command wrapper. Plumbing `command` through serialization is a // separate, larger change. - const spawnEnv = this.buildSpawnEnv(revivedEnv, { - id: entry.id, - paneKey: entry.paneKey, - shell - }) + const spawnEnv = this.buildSpawnEnv( + revivedEnv, + { + id: entry.id, + paneKey: entry.paneKey, + shell + }, + envToDelete + ) const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) const term = ptyMod.spawn(shell, shellLaunch.args, { - name: 'xterm-256color', + // Why: revive must preserve the same terminal identity as fresh spawn. + name: spawnEnv.TERM ?? 'xterm-256color', cols: entry.cols, rows: entry.rows, cwd: entry.cwd, @@ -1008,6 +1078,8 @@ export class PtyHandler { tabId: entry.tabId, attachIdentity: entry.attachIdentity, worktreeId: entry.worktreeId, + ...(explicitTerm !== undefined ? { explicitTerm } : {}), + envToDelete, ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx index 0aedcc811bf..968ceb6a5a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -76,7 +76,7 @@ describe('NativeChatSessionGate', () => { renderResolution({ paneKey: 'tab-1:leaf-1', launchAgent: null, - resolvedAgent: 'grok', + resolvedAgent: 'gemini', ptyId: 'pty-1' }) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index fe2e3d6de8f..8956355fa51 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { canToggleNativeChat } from './native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' describe('canToggleNativeChat', () => { it('allows a terminal launched with a supported coding agent', () => { @@ -55,16 +56,53 @@ describe('canToggleNativeChat', () => { ).toBe(true) }) - it('rejects an unsupported launch agent (Grok)', () => { + it('accepts local Grok once native chat can parse its transcript', () => { expect( canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', - launchAgent: 'grok' + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(null) + }) + ).toBe(true) + }) + + it('accepts runtime-owned Grok because Model B reads the transcript locally', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + 'runtime-ssh-env-1' + ) + }) + ).toBe(true) + }) + + it('rejects Model-A SSH Grok when its transcript is remote-only', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1') }) ).toBe(false) }) + it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: false, + isChatViewMode: true + }) + ).toBe(true) + }) + it('rejects an unsupported agent detected live (Gemini)', () => { expect( canToggleNativeChat({ @@ -76,15 +114,16 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) - it('rejects an unsupported agent resolved from the title', () => { + it('accepts Grok when resolved from the title', () => { expect( canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, - resolvedAgent: 'grok' + resolvedAgent: 'grok', + nativeChatTranscriptIsLocalReadable: true }) - ).toBe(false) + ).toBe(true) }) it('rejects a stale supported title when live detection found an unsupported agent', () => { @@ -115,7 +154,7 @@ describe('canToggleNativeChat', () => { canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', - launchAgent: 'grok', + launchAgent: 'gemini', resolvedAgent: 'claude' }) ).toBe(false) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 4e520af1b9a..3d2be38e376 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -22,6 +22,8 @@ export type NativeChatAvailabilityInput = { * terminal title resolver) when it identifies the foreground as an agent * before hooks arrive. */ resolvedAgent?: TuiAgent | null + /** Whether this renderer's native-chat reader can access the agent transcript. */ + nativeChatTranscriptIsLocalReadable?: boolean /** Already-chat tabs must always be allowed to toggle back to terminal, even * if live hook state was lost during a dev/app restart. */ isChatViewMode?: boolean @@ -30,9 +32,9 @@ export type NativeChatAvailabilityInput = { /** Native chat is a rendering of a coding-agent conversation, so the toggle is * only meaningful on terminals that actually run an agent we can parse. Plain * shells, non-terminal surfaces (editor, browser, …), and unsupported agents - * (Grok, Gemini, …) never qualify. Live identity is authoritative when present; + * (Gemini, …) never qualify. Live identity is authoritative when present; * launch metadata is next, and title resolution only fills the pre-hook gap for - * manually-started Claude/Codex sessions. */ + * manually-started Claude/Codex/Grok sessions. */ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean { if (input.experimentalNativeChatEnabled !== true) { return false @@ -44,5 +46,8 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean return true } const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent + if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) { + return false + } return isNativeChatSupportedAgent(agent) } diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts index 2ded9d310b3..35fdc107695 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts @@ -19,6 +19,14 @@ describe('image paste agent map', () => { }) }) + it('grok attaches image paths like other vision-capable TUIs', () => { + expect(getAgentImageHandling('grok')).toBe('attachment') + expect(resolveImagePaste('grok', '/tmp/orca-paste-1.png')).toEqual({ + kind: 'attach', + path: '/tmp/orca-paste-1.png' + }) + }) + it('unknown/custom agent is unsupported', () => { expect(getAgentImageHandling('some-custom-agent')).toBe('unsupported') expect(resolveImagePaste('some-custom-agent', '/tmp/x.png')).toEqual({ diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index fb5adf67d87..792d10445ed 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -19,7 +19,11 @@ const IMAGE_ATTACHMENT_AGENTS: ReadonlySet = new Set([ 'gemini', 'cursor', 'copilot', - 'droid' + 'droid', + // Why: Grok CLI pastes images via bracketed path / image chips (see xAI + // terminal docs + pager paste.rs). Keep it on the same attachment path as + // Claude/Codex rather than treating path paste as unsupported text. + 'grok' ]) export function getAgentImageHandling(agent: AgentType): AgentImageHandling { diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts index 73c291bb4e2..1193450e3cb 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts @@ -160,19 +160,31 @@ describe('resolveNativeChatSession', () => { } ) - it.each(['gemini', 'grok'] as TuiAgent[])( - 'does not resolve unsupported title fallback %s', - (resolvedAgent) => { - expect( - resolveNativeChatSession({ - paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', - launchAgent: null, - resolvedAgent, - ptyId: 'pty-1' - }) - ).toBeNull() - } - ) + it('does not resolve unsupported title fallback gemini', () => { + expect( + resolveNativeChatSession({ + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + launchAgent: null, + resolvedAgent: 'gemini', + ptyId: 'pty-1' + }) + ).toBeNull() + }) + + it('resolves Grok from title fallback once native chat supports its transcript', () => { + expect( + resolveNativeChatSession({ + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + launchAgent: null, + resolvedAgent: 'grok', + ptyId: 'pty-1' + }) + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) + }) it('does not resolve an unsupported live status entry', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -224,17 +236,21 @@ describe('resolveNativeChatSession', () => { ).toBeNull() }) - it('does not resolve an unsupported launch agent', () => { + it('resolves a Grok launch agent', () => { expect( resolveNativeChatSession({ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', launchAgent: 'grok', ptyId: 'pty-1' }) - ).toBeNull() + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) }) - it('does not fall back to a supported title agent when launchAgent is unsupported', () => { + it('keeps Grok launch identity ahead of a different title agent', () => { expect( resolveNativeChatSession({ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', @@ -242,7 +258,11 @@ describe('resolveNativeChatSession', () => { resolvedAgent: 'codex', ptyId: 'pty-1' }) - ).toBeNull() + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) }) it('keeps launch identity ahead of the title fallback', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 5ae84dde522..c53b5263de0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -4,7 +4,9 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalPaneLayoutNode } from '../../../../shared/types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' +import { getConnectionIdFromState } from '@/lib/connection-context' export function isNativeChatShortcutTitleFallbackSafe( root: TerminalPaneLayoutNode | null | undefined @@ -61,7 +63,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive (candidate) => candidate.id === tab.entityId ) // Carry the agent identity (not just "an agent exists") so the chord stays - // inert on unsupported agents like Grok, matching the menu/header gate. + // inert on unsupported agents (e.g. Gemini), matching the menu/header gate. // Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] @@ -82,6 +84,9 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive launchAgent: detectedAgent ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : titleFallbackAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(state, worktreeId) + ), isChatViewMode: tab.viewMode === 'chat' }) ) { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 1b95beecf5b..a8c25191172 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -26,7 +26,7 @@ export function NativeChatExperimentalSetting({ title={translate('auto.components.settings.ExperimentalPane.nativeChat.title', 'Native chat')} description={translate( 'auto.components.settings.ExperimentalPane.nativeChat.description', - 'Preview the desktop chat surface for Claude and Codex terminal sessions.' + 'Preview the desktop chat surface for supported agent terminal sessions.' )} keywords={getExperimentalSearchEntry().nativeChat.keywords} className="space-y-3 py-2" @@ -40,7 +40,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Adds a native chat view you can switch to from supported Claude and Codex terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' + 'Adds a native chat view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' )}

@@ -70,7 +70,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.defaultCopy', - 'Choose how new Claude and Codex terminal tabs open.' + 'Choose how new supported agent terminal tabs open.' )}

diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index b97b053ad9c..2d4f55bec61 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -329,7 +329,7 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.69c64a479c', - 'Let tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).' + 'Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).' )} keywords={[ 'osc 52', @@ -339,6 +339,7 @@ export function TerminalInteractionSection({ 'neovim', 'nvim', 'fzf', + 'grok', 'ssh', 'remote', 'copy', @@ -352,7 +353,7 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.6e6480a7df', - 'Let programs in the terminal (tmux, Neovim, fzf, SSH) copy to your system clipboard.' + 'Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.' )} checked={settings.terminalAllowOsc52Clipboard} onChange={() => diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts index a1b92429180..2d74f8dd1d9 100644 --- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts @@ -10,7 +10,7 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { ), description: translate( 'auto.components.settings.experimental.search.nativeChat.description', - 'Preview the desktop chat surface for Claude and Codex terminal sessions.' + 'Preview the desktop chat surface for supported agent terminal sessions.' ), keywords: [ ...translateSearchKeyword( @@ -33,6 +33,10 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { 'auto.components.settings.experimental.search.nativeChat.codex', 'codex' ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.nativeChat.grok', + 'grok' + ), ...translateSearchKeyword( 'auto.components.settings.experimental.search.nativeChat.terminal', 'terminal' diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index e5e9c328a6e..2632fffe8bd 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -79,8 +79,10 @@ import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { getConnectionIdFromState } from '@/lib/connection-context' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -463,6 +465,9 @@ function TabBarInner({ useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) + const nativeChatTranscriptIsLocalReadable = useAppStore((s) => + isNativeChatTranscriptLocalReadable(getConnectionIdFromState(s, worktreeId)) + ) // Why: Electron elements run in a separate process, so clicking // inside one never dispatches a pointerdown on the renderer document. @@ -1124,6 +1129,7 @@ function TabBarInner({ launchAgent: terminalTab.launchAgent, detectedAgent, resolvedAgent, + nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) return ( diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 7bdb5d20392..003188d6c16 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -99,6 +99,7 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentType } from '../../../../shared/agent-status-types' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -316,6 +317,9 @@ export default function TerminalPane({ } return connectionId }) + const nativeChatTranscriptIsLocalReadable = useAppStore((store) => + isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) + ) // Which machine's SSH store this target belongs to: a remote Orca server's // per-environment bucket, or null for this machine's local SSH maps. The // explicit-owner resolver never lets a merely focused runtime make a @@ -719,6 +723,7 @@ export default function TerminalPane({ launchAgent: detectedAgent ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), + nativeChatTranscriptIsLocalReadable, isChatViewMode: isChatViewForLeaf }) }, @@ -727,6 +732,7 @@ export default function TerminalPane({ effectiveChatViewMode, chatLeafId, nativeChatEnabled, + nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, resolveTitleAgentForLeaf ] 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 9368f810cbf..662e46aae52 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5630,6 +5630,24 @@ describe('connectPanePty', () => { expect(transport.sendInput).toHaveBeenCalledWith('yes') expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') expect(transport.sendInputImmediate).not.toHaveBeenCalled() + + // terminal-query-reply.test proves real xterm emits this as one fully framed + // onData reply; this pins that production-shaped reply to the immediate path. + transport.sendInputImmediate.mockClear() + const xtversionReply = '\x1bP>|xterm.js(6.1.0-beta.287)\x1b\\' + sendTerminalInputThroughPane(pane, xtversionReply) + expect(transport.sendInputImmediate).toHaveBeenCalledWith(xtversionReply) + + // Printable input is user-owned. Remote cooked echo comes back through PTY + // output, not onData, so xterm/OSC-looking text must stay on normal input. + transport.sendInput.mockClear() + transport.sendInputImmediate.mockClear() + const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] + for (const data of printableInputs) { + sendTerminalInputThroughPane(pane, data) + expect(transport.sendInput).toHaveBeenCalledWith(data) + } + expect(transport.sendInputImmediate).not.toHaveBeenCalled() }) it('writes the onReplayData pendingEscapeTailAnsi meta last, after the replayed bytes (#7329)', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts new file mode 100644 index 00000000000..45131aeb513 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' +import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' +import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' + +const localWindowsConpty = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe', + executionHostId: 'local' as const +} + +function resolvedKittyKeyboard( + startup: { launchAgent?: 'grok' } | null | undefined, + tabLaunchAgent: 'grok' | null = null +): boolean | undefined { + const tuiAgent = resolvePaneKeyboardProtocolAgent(startup, tabLaunchAgent) + const options = { + ...buildDefaultTerminalOptions(), + ...buildTerminalKeyboardProtocolOptions({ ...localWindowsConpty, tuiAgent }) + } + return options.vtExtensions?.kittyKeyboard +} + +describe('pane-scoped terminal keyboard protocol agent', () => { + it('keeps KKP for the Grok startup pane but withholds it from a later shell split', () => { + expect(resolvedKittyKeyboard({ launchAgent: 'grok' })).toBe(true) + + // The lifecycle consumes its one-shot startup before a user-created split. + expect(resolvedKittyKeyboard(null)).toBe(false) + }) + + it('uses persisted Grok identity for a restored root but not its later sibling', () => { + expect(resolvedKittyKeyboard(undefined, 'grok')).toBe(true) + + // onPaneCreated changes the lifecycle startup sentinel from undefined to null. + expect(resolvedKittyKeyboard(null, 'grok')).toBe(false) + }) + + it('keeps explicit pane startup identity ahead of a stale tab launch agent', () => { + expect(resolvePaneKeyboardProtocolAgent({ launchAgent: 'claude' }, 'grok')).toBe('claude') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts new file mode 100644 index 00000000000..89fb02cbd67 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts @@ -0,0 +1,16 @@ +import type { TuiAgent } from '../../../../shared/types' + +type PaneKeyboardProtocolStartup = { + launchAgent?: TuiAgent +} + +/** Resolves only the agent owned by the startup payload for the pane being created. */ +export function resolvePaneKeyboardProtocolAgent( + startup: PaneKeyboardProtocolStartup | null | undefined, + tabLaunchAgent?: TuiAgent | null +): TuiAgent | null { + if (startup === undefined) { + return tabLaunchAgent ?? null + } + return startup?.launchAgent ?? 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 132a52c36d0..1f590985dce 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 @@ -18,6 +18,7 @@ import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-he import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' +import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' import { useAppStore } from '@/store' import { createFilePathLinkProvider, @@ -1374,19 +1375,27 @@ export function useTerminalPaneLifecycle({ (candidate) => candidate.id === tabId ) const platformInfo = window.api.platform?.get?.() + // Why: launch identity belongs only to the pane consuming this one-shot + // startup. A tab-wide hint would leak Grok's KKP exception to shell splits. + const knownTuiAgent = resolvePaneKeyboardProtocolAgent( + ptyDeps.startup, + currentTab?.launchAgent + ) const ptyBackendContext = { userAgent: navigator.userAgent, osRelease: platformInfo?.osRelease, connectionId: getConnectionId(worktreeId), cwd: startupCwd, shellOverride: currentTab?.shellOverride, - executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId) + executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId), + tuiAgent: knownTuiAgent } const windowsPtyCompatibilityOptions = buildWindowsPtyCompatibilityOptions(ptyBackendContext) // Why: local Windows ConPTY CLIs read the Kitty keyboard advertisement but // do not decode CSI-u, so withhold it there to restore Enter/Up/Down nav // (issue #2434); SSH and macOS/Linux panes keep enhanced reporting. + // Exception: Grok (tuiAgent) keeps the advertisement — see protocol module. const keyboardProtocolOptions = buildTerminalKeyboardProtocolOptions(ptyBackendContext) return { ...windowsPtyCompatibilityOptions, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index fab0f38e013..49867868e90 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -126,6 +126,8 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { translate } from '@/i18n/i18n' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -1484,7 +1486,11 @@ export function useIpcEvents(): void { ? { launchAgent, ...initialAgentTabViewModeProps(store.settings, { - agent: launchAgent + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) }) } : {}), @@ -1666,7 +1672,10 @@ export function useIpcEvents(): void { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, ...initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) }), ...(data.cwd ? { startupCwd: data.cwd } : {}) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 004a9d9e3e9..8a91f79653c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5308,11 +5308,11 @@ "0e89a574ae": "Floating animated pet in the bottom-right corner.", "nativeChat": { "title": "Native chat", - "description": "Preview the desktop chat surface for Claude and Codex terminal sessions.", - "copy": "Adds a native chat view you can switch to from supported Claude and Codex terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", + "description": "Preview the desktop chat surface for supported agent terminal sessions.", + "copy": "Adds a native chat view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", "toggleLabel": "Toggle native chat", "defaultTitle": "Default view", - "defaultCopy": "Choose how new Claude and Codex terminal tabs open.", + "defaultCopy": "Choose how new supported agent terminal tabs open.", "defaultViewLabel": "Default native chat view", "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Native chat" @@ -6723,9 +6723,9 @@ "d23b43c5be": "Setup Script Location", "34a0dfa06e": "Where the repository setup script runs when a new workspace is created.", "21f8da2078": "Workspace Setup Script", - "6e6480a7df": "Let programs in the terminal (tmux, Neovim, fzf, SSH) copy to your system clipboard.", + "6e6480a7df": "Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.", "3338dcf8c1": "Allow TUI Clipboard Writes (OSC 52)", - "69c64a479c": "Let tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", + "69c64a479c": "Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", "4729c645fc": "Automatically copy terminal selections to the clipboard.", "902f5dee1f": "Copy on Select", "9129b7e805": "Hovering a terminal pane activates it without needing to click.", @@ -7517,7 +7517,8 @@ "87d99e634b": "Pet", "nativeChat": { "title": "Native chat", - "description": "Preview the desktop chat surface for Claude and Codex terminal sessions." + "description": "Preview the desktop chat surface for supported agent terminal sessions.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b8d4ca45d95..694ea52f824 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5308,11 +5308,11 @@ }, "nativeChat": { "title": "Chat nativo", - "description": "Previsualiza la superficie de chat de escritorio para sesiones de terminal de Claude y Codex.", - "copy": "Agrega una vista de chat nativa a la que puedes cambiar desde paneles de terminal compatibles de Claude y Codex. Experimental mientras ajustamos la fidelidad de transcripción, streaming y paridad con el terminal.", + "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", + "copy": "Añade una vista de chat nativo a la que puedes cambiar desde paneles de terminal de agentes compatibles. Es experimental mientras ajustamos la fidelidad de las transcripciones, la transmisión y la paridad con el terminal.", "toggleLabel": "Alternar chat nativo", "defaultTitle": "Vista predeterminada", - "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de Claude y Codex.", + "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de agentes compatibles.", "defaultViewLabel": "Vista predeterminada de chat nativo", "defaultViewTerminal": "Chat en terminal", "defaultViewNative": "Chat nativo" @@ -6686,9 +6686,9 @@ "d23b43c5be": "Ubicación del script de configuración", "34a0dfa06e": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo.", "21f8da2078": "Script de configuración del espacio de trabajo", - "6e6480a7df": "Permite que los programas del terminal (tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", + "6e6480a7df": "Permite que los programas del terminal (Grok, tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "69c64a479c": "Permite que tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", + "69c64a479c": "Permite que Grok, tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", @@ -7480,7 +7480,8 @@ }, "nativeChat": { "title": "Chat nativo", - "description": "Previsualiza la vista de chat de escritorio para sesiones de terminal de Claude y Codex." + "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d35dc5d81ef..59b3e7640c2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5293,11 +5293,11 @@ }, "nativeChat": { "title": "ネイティブチャット", - "description": "Claude と Codex の terminal セッションのデスクトップ チャット サーフェスをプレビューします。", - "copy": "サポートされている Claude および Codex の terminal ペインから切り替えることができるネイティブ チャット ビューを追加します。トランスクリプトの忠実度、ストリーミング、ターミナルのパリティを調整するための実験です。", + "description": "対応エージェントのターミナルセッション用デスクトップチャット画面をプレビューします。", + "copy": "対応エージェントのターミナルペインから切り替えられるネイティブチャットビューを追加します。トランスクリプトの忠実度、ストリーミング、ターミナルとの同等性を調整しているため、試験運用中です。", "toggleLabel": "ネイティブチャットの切り替え", "defaultTitle": "デフォルトのビュー", - "defaultCopy": "新規 Claude および Codex の terminal タブを開く方法を選択します。", + "defaultCopy": "対応エージェントの新しいターミナルタブを開く方法を選択します。", "defaultViewLabel": "デフォルトのネイティブ チャット ビュー", "defaultViewTerminal": "Terminal チャット", "defaultViewNative": "ネイティブチャット" @@ -6708,9 +6708,9 @@ "d23b43c5be": "セットアップスクリプトの場所", "34a0dfa06e": "新規ワークスペースの作成時に repos セットアップ スクリプトが実行される場所。", "21f8da2078": "ワークスペースセットアップスクリプト", - "6e6480a7df": "terminal 内のプログラム (tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", + "6e6480a7df": "terminal 内のプログラム (Grok、tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", "3338dcf8c1": "TUI クリップボードへの書き込みを許可する (OSC 52)", - "69c64a479c": "tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", + "69c64a479c": "Grok、tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", "4729c645fc": "terminal の選択内容をクリップボードに自動的にコピーします。", "902f5dee1f": "選択時にコピー", "9129b7e805": "terminal ペインにマウスを移動すると、クリックしなくても terminal ペインがアクティブになります。", @@ -7502,7 +7502,8 @@ }, "nativeChat": { "title": "ネイティブチャット", - "description": "Claude と Codex の terminal セッションのデスクトップ チャット サーフェスをプレビューします。" + "description": "対応エージェントのターミナルセッション用デスクトップチャット画面をプレビューします。", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 390d5836d5a..3a81820ec53 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5293,11 +5293,11 @@ }, "nativeChat": { "title": "네이티브 채팅", - "description": "Claude 및 Codex terminal 세션에 대한 데스크톱 채팅 화면을 미리 봅니다.", - "copy": "지원되는 Claude 및 Codex terminal 창에서 전환할 수 있는 기본 채팅 보기를 추가합니다. 성적표 충실도, 스트리밍 및 terminal 패리티를 조정하는 동안 실험적입니다.", + "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", + "copy": "지원되는 에이전트 터미널 창에서 전환할 수 있는 네이티브 채팅 보기를 추가합니다. 대화 기록 충실도, 스트리밍, 터미널 동작 일치를 조정하는 동안 실험적으로 제공됩니다.", "toggleLabel": "기본 채팅 전환", "defaultTitle": "기본 보기", - "defaultCopy": "새로운 Claude 및 Codex terminal 탭이 열리는 방식을 선택하세요.", + "defaultCopy": "지원되는 에이전트의 새 터미널 탭을 여는 방식을 선택합니다.", "defaultViewLabel": "기본 기본 채팅 보기", "defaultViewTerminal": "Terminal 채팅", "defaultViewNative": "네이티브 채팅" @@ -6671,9 +6671,9 @@ "d23b43c5be": "설정 스크립트 위치", "34a0dfa06e": "새 워크스페이스를 만들 때 repos 설정 스크립트를 실행할 위치입니다.", "21f8da2078": "워크스페이스 설정 스크립트", - "6e6480a7df": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", + "6e6480a7df": "terminal의 프로그램(Grok, tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", - "69c64a479c": "tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", + "69c64a479c": "Grok, tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", "4729c645fc": "terminal 선택 사항을 클립보드에 자동으로 복사합니다.", "902f5dee1f": "선택 시 복사", "9129b7e805": "terminal 패널에 마우스를 가리키면 클릭하지 않고도 활성화됩니다.", @@ -7465,7 +7465,8 @@ }, "nativeChat": { "title": "네이티브 채팅", - "description": "Claude 및 Codex terminal 세션에 대한 데스크톱 채팅 화면을 미리 봅니다." + "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dee19362aa3..ec6987a5999 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5293,11 +5293,11 @@ }, "nativeChat": { "title": "原生聊天", - "description": "预览 Claude 和 Codex 终端会话的桌面聊天界面。", - "copy": "添加一个原生聊天视图,可从受支持的 Claude 和 Codex 终端窗格切换进入。在我们调校记录保真度、流式输出和终端一致性期间,此功能仍为实验性。", + "description": "预览受支持的智能体终端会话的桌面聊天界面。", + "copy": "添加可从受支持的智能体终端窗格切换的原生聊天视图。在我们调整记录保真度、流式传输和终端一致性期间,此功能仍处于实验阶段。", "toggleLabel": "切换原生聊天", "defaultTitle": "默认视图", - "defaultCopy": "选择新的 Claude 和 Codex 终端标签页如何打开。", + "defaultCopy": "选择如何打开受支持智能体的新终端标签页。", "defaultViewLabel": "默认原生聊天视图", "defaultViewTerminal": "终端聊天", "defaultViewNative": "原生聊天" @@ -6671,9 +6671,9 @@ "d23b43c5be": "安装脚本位置", "34a0dfa06e": "创建新工作区时运行存储库设置脚本的位置。", "21f8da2078": "工作区设置脚本", - "6e6480a7df": "让终端中的程序(tmux、Neovim、fzf、SSH)复制到系统剪贴板。", + "6e6480a7df": "让终端中的程序(Grok、tmux、Neovim、fzf、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", - "69c64a479c": "让 tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", + "69c64a479c": "让 Grok、tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", @@ -7465,7 +7465,8 @@ }, "nativeChat": { "title": "原生聊天", - "description": "预览 Claude 和 Codex 终端会话的桌面聊天界面。" + "description": "预览受支持的智能体终端会话的桌面聊天界面。", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts new file mode 100644 index 00000000000..faf341c957b --- /dev/null +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' +import ja from './locales/ja.json' +import ko from './locales/ko.json' +import zh from './locales/zh.json' + +const localizedCatalogs = { es, ja, ko, zh } +const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat +const englishSearch = en.auto.components.settings.experimental.search.nativeChat + +describe('native chat locale copy', () => { + it.each(Object.entries(localizedCatalogs))('%s keeps provider-neutral copy localized', (_code, catalog) => { + const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat + const search = catalog.auto.components.settings.experimental.search.nativeChat + for (const [localized, english] of [ + [setting.description, englishSetting.description], + [setting.copy, englishSetting.copy], + [setting.defaultCopy, englishSetting.defaultCopy], + [search.description, englishSearch.description] + ]) { + expect(localized.trim()).not.toBe('') + expect(localized).not.toBe(english) + } + expect(search.grok).toBe('grok') + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 577db1e534e..5ce6ea89740 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -65,7 +65,7 @@ describe('deliverLaunchPromptToAgentTab', () => { }) await deliverLaunchPromptToAgentTab({ tabId: 'unsupported-tab', - agent: 'grok', + agent: 'gemini', content: 'Fix failing checks', submit: true, forcePaste: true @@ -121,7 +121,7 @@ describe('deliverLaunchPromptToAgentTab', () => { await deliverLaunchPromptToAgentTab({ tabId: 'tab-1', - agent: 'grok', + agent: 'gemini', content: 'Large generated prompt', submit: true, forcePaste: true diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fd25fc85df6..fa7283725c4 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -201,7 +201,7 @@ describe('launchAgentInNewTab', () => { }) }) - it('keeps unsupported submit-after-ready launches in terminal mode and does not seed chat', async () => { + it('opens local Grok submit-after-ready launches in native chat', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -220,9 +220,36 @@ describe('launchAgentInNewTab', () => { }) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'grok' + launchAgent: 'grok', + quickCommandLabel: undefined, + viewMode: 'chat' + }) + expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'grok', + text: 'large generated prompt', + createdAt: expect.any(Number) + }) + }) + + it('keeps Model-A SSH Grok launches in terminal mode', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'grok', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'grok', + quickCommandLabel: undefined }) - expect(mockSeedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) it('passes quick command labels only to locally-created agent tabs', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 50397347791..174fdd86ff0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -11,6 +11,7 @@ import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { @@ -29,6 +30,7 @@ import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt- import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' +import { getConnectionIdFromState } from '@/lib/connection-context' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -264,14 +266,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const viewModePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, ...initialAgentTabViewModeProps(store.settings, { agent, - promptDelivery: viewModePromptDelivery + promptDelivery: viewModePromptDelivery, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) }) }) store.queueTabStartupCommand(tab.id, { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index bc8d1553f2b..10996951048 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -3,6 +3,7 @@ import { decideInitialAgentTabViewMode, initialAgentTabViewModeProps } from './native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' describe('decideInitialAgentTabViewMode', () => { it("returns 'chat' when native chat and the opt-in default setting are on", () => { @@ -50,11 +51,47 @@ describe('decideInitialAgentTabViewMode', () => { decideInitialAgentTabViewMode({ experimentalNativeChat: true, openAgentTabsInChatByDefault: true, - agent: 'grok' + agent: 'gemini' }) ).toBeUndefined() }) + it.each([ + ['local', null], + ['runtime-owned', 'runtime-ssh-env-1'] + ] as const)('opens %s Grok in chat when configured', (_host, connectionId) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok', + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable(connectionId) + }) + ).toBe('chat') + }) + + it('keeps Model-A SSH Grok in the terminal view', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok', + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable('ssh-target-1') + }) + ).toBeUndefined() + expect( + initialAgentTabViewModeProps( + { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + { agent: 'grok', nativeChatTranscriptIsLocalReadable: false } + ) + ).toEqual({}) + }) + it('returns undefined for draft delivery', () => { expect( decideInitialAgentTabViewMode({ diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index a79b60962e9..fd0e71c6d41 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -16,6 +16,7 @@ export function decideInitialAgentTabViewMode(args: { openAgentTabsInChatByDefault?: boolean agent?: TuiAgent | null promptDelivery?: NativeChatLaunchPromptDelivery + nativeChatTranscriptIsLocalReadable?: boolean }): Tab['viewMode'] { if (args.experimentalNativeChat !== true || args.openAgentTabsInChatByDefault !== true) { return undefined @@ -23,6 +24,9 @@ export function decideInitialAgentTabViewMode(args: { if (!isNativeChatSupportedAgent(args.agent)) { return undefined } + if (args.agent === 'grok' && args.nativeChatTranscriptIsLocalReadable !== true) { + return undefined + } if (args.promptDelivery === 'draft') { return undefined } @@ -37,13 +41,15 @@ export function initialAgentTabViewModeProps( options: { agent?: TuiAgent | null promptDelivery?: NativeChatLaunchPromptDelivery + nativeChatTranscriptIsLocalReadable?: boolean } = {} ): { viewMode?: Tab['viewMode'] } { const viewMode = decideInitialAgentTabViewMode({ experimentalNativeChat: settings?.experimentalNativeChat, openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, agent: options.agent, - promptDelivery: options.promptDelivery + promptDelivery: options.promptDelivery, + nativeChatTranscriptIsLocalReadable: options.nativeChatTranscriptIsLocalReadable }) return viewMode ? { viewMode } : {} } diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index ec24cd3968d..a376fe0d5ec 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -5,7 +5,11 @@ import type { TuiAgent } from '../../../shared/types' export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ 'claude', 'openclaude', - 'codex' + 'codex', + // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and + // Orca already reads that for hooks/AI Vault — native chat reuses the same + // path + a Grok JSONL decoder rather than leaving Grok TUI-only. + 'grok' ]) export function isNativeChatSupportedAgent( diff --git a/src/renderer/src/lib/native-chat-transcript-readability.test.ts b/src/renderer/src/lib/native-chat-transcript-readability.test.ts new file mode 100644 index 00000000000..29d41bcc7bd --- /dev/null +++ b/src/renderer/src/lib/native-chat-transcript-readability.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' + +describe('native chat transcript readability', () => { + it('allows local and runtime-owned hosts but rejects Model-A SSH and unresolved hosts', () => { + expect(isNativeChatTranscriptLocalReadable(null)).toBe(true) + expect(isNativeChatTranscriptLocalReadable('runtime-ssh-env-1')).toBe(true) + expect(isNativeChatTranscriptLocalReadable('ssh-target-1')).toBe(false) + expect(isNativeChatTranscriptLocalReadable(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/native-chat-transcript-readability.ts b/src/renderer/src/lib/native-chat-transcript-readability.ts new file mode 100644 index 00000000000..165b2a36a1a --- /dev/null +++ b/src/renderer/src/lib/native-chat-transcript-readability.ts @@ -0,0 +1,8 @@ +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' + +/** Model-A SSH stores Grok transcripts on a host this renderer cannot read. */ +export function isNativeChatTranscriptLocalReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} 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 e96a8efec62..c4cb7808225 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -149,6 +149,25 @@ describe('buildDefaultTerminalOptions', () => { expect(merged.vtExtensions?.kittyKeyboard).toBe(false) }) + it('keeps kitty keyboard when a local Windows ConPTY pane is launching Grok', () => { + // Why: Grok needs KKP for Ctrl+Enter interject / Shift+Enter newline; the + // ConPTY withhold must not win when launchAgent (or tuiAgent) is grok. + const merged = { + ...buildDefaultTerminalOptions(), + ...buildTerminalKeyboardProtocolOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe', + executionHostId: 'local', + tuiAgent: 'grok' + }) + } + + expect(merged.vtExtensions?.kittyKeyboard).toBe(true) + }) + it('keeps the advertised kitty keyboard default for SSH and macOS/Linux panes', () => { for (const context of [ { diff --git a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts index 8c525a93aeb..87a596736ab 100644 --- a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildTerminalKeyboardProtocolOptions, + prefersKittyKeyboardDespiteWindowsConpty, shouldDisableKittyKeyboardForTerminal } from './terminal-keyboard-protocol' @@ -8,19 +9,50 @@ const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' const LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64)' +const localWindowsConpty = { + userAgent: WINDOWS_UA, + osRelease: '10.0.26100', + connectionId: null as string | null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe' as string | null, + executionHostId: 'local' as const +} + +describe('prefersKittyKeyboardDespiteWindowsConpty', () => { + it('is true only for Grok (needs KKP for modified Enter on ConPTY)', () => { + expect(prefersKittyKeyboardDespiteWindowsConpty('grok')).toBe(true) + expect(prefersKittyKeyboardDespiteWindowsConpty('claude')).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty('codex')).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty(null)).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty(undefined)).toBe(false) + }) +}) + describe('shouldDisableKittyKeyboardForTerminal', () => { it('disables Kitty keyboard for a local native Windows ConPTY pane', () => { // Regression for #2434: local Windows CLIs (e.g. Antigravity agy) read the // advertisement but do not decode CSI-u, so enhanced reporting swallows // Enter/Up/Down navigation. The advertisement must be withheld here. + expect(shouldDisableKittyKeyboardForTerminal(localWindowsConpty)).toBe(true) + }) + + it('keeps Kitty keyboard for Grok on a local Windows ConPTY pane', () => { + // Why: Grok needs KKP to distinguish Ctrl+Enter (interject) / Shift+Enter + // from plain Enter. Global ConPTY withhold must not apply when the pane's + // known agent is Grok (Orca launchAgent or equivalent). expect( shouldDisableKittyKeyboardForTerminal({ - userAgent: WINDOWS_UA, - osRelease: '10.0.26100', - connectionId: null, - cwd: 'C:\\repo', - shellOverride: 'powershell.exe', - executionHostId: 'local' + ...localWindowsConpty, + tuiAgent: 'grok' + }) + ).toBe(false) + }) + + it('still withholds Kitty keyboard for non-Grok agents on local Windows ConPTY', () => { + expect( + shouldDisableKittyKeyboardForTerminal({ + ...localWindowsConpty, + tuiAgent: 'claude' }) ).toBe(true) }) @@ -109,16 +141,18 @@ describe('shouldDisableKittyKeyboardForTerminal', () => { describe('buildTerminalKeyboardProtocolOptions', () => { it('withholds the Kitty keyboard advertisement for a local Windows ConPTY pane', () => { + expect(buildTerminalKeyboardProtocolOptions(localWindowsConpty)).toEqual({ + vtExtensions: { kittyKeyboard: false } + }) + }) + + it('returns no override for Grok on local Windows ConPTY so KKP stays advertised', () => { expect( buildTerminalKeyboardProtocolOptions({ - userAgent: WINDOWS_UA, - osRelease: '10.0.26100', - connectionId: null, - cwd: 'C:\\repo', - shellOverride: 'powershell.exe', - executionHostId: 'local' + ...localWindowsConpty, + tuiAgent: 'grok' }) - ).toEqual({ vtExtensions: { kittyKeyboard: false } }) + ).toEqual({}) }) it('returns no override for SSH panes so enhanced reporting stays advertised', () => { diff --git a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts index 0d591c473b6..8c804236f9b 100644 --- a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts +++ b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts @@ -1,5 +1,6 @@ import type { ITerminalOptions } from '@xterm/xterm' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TuiAgent } from '../../../../shared/types' import { isLocalNativeWindowsConpty, type WindowsPtyCompatibilityContext @@ -7,6 +8,23 @@ import { export type TerminalKeyboardProtocolContext = WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId + /** + * Known TUI agent for this pane when available (launchAgent / foreground). + * Used only to opt specific agents out of the ConPTY KKP withhold. + */ + tuiAgent?: TuiAgent | null +} + +/** + * Agents that correctly decode Kitty CSI-u and need modified-Enter chords + * (Shift/Ctrl+Enter) on local Windows ConPTY. Global ConPTY withhold (#2434) + * targets CSI-u-blind CLIs (e.g. Antigravity); Grok is not in that set and + * relies on KKP for interject vs newline (official Grok Build keyboard docs). + */ +export function prefersKittyKeyboardDespiteWindowsConpty( + agent: TuiAgent | null | undefined +): boolean { + return agent === 'grok' } /** @@ -21,10 +39,16 @@ export type TerminalKeyboardProtocolContext = WindowsPtyCompatibilityContext & { * a genuine local Windows ConPTY pane restores standard navigation there while * preserving enhanced keyboard reporting for SSH and macOS/Linux panes (which * decode CSI-u correctly, including inside tmux). + * + * Exception: when `tuiAgent` is an agent known to need KKP on ConPTY (Grok), + * keep the advertisement so modified-Enter chords stay usable. */ export function shouldDisableKittyKeyboardForTerminal( context: TerminalKeyboardProtocolContext ): boolean { + if (prefersKittyKeyboardDespiteWindowsConpty(context.tuiAgent)) { + return false + } return isLocalNativeWindowsConpty(context) } diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 1feb1e44c7a..14af5e911f9 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -166,7 +166,7 @@ describe('buildAgentStartupPlan', () => { }) }) - it('launches Grok first and injects the prompt after startup', () => { + it('launches Grok with the prompt as a positional argv', () => { expect( buildAgentStartupPlan({ agent: 'grok', @@ -176,9 +176,9 @@ describe('buildAgentStartupPlan', () => { }) ).toEqual({ agent: 'grok', - launchCommand: 'grok', + launchCommand: "grok -- 'Trace the failing test'", expectedProcess: 'grok', - followupPrompt: 'Trace the failing test', + followupPrompt: null, launchConfig: emptyLaunchConfig('grok') }) }) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 1d94786a345..52004e147dd 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -63,6 +63,8 @@ import { } from './folder-workspace-path-status' import { toast } from 'sonner' import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' +import { getConnectionId } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -582,7 +584,10 @@ export function ensureWorktreeHasInitialTerminal( launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null, { agent: launchAgent, - promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined + promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) }) } : {}), @@ -647,7 +652,10 @@ function applyDefaultTerminalTabs( launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null, { agent: launchAgent, - promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined + promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) }) } : {}), diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 3d4baeac3c4..943902b9bc0 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -13,11 +13,16 @@ import { isShellSafeEndpointValue, normalizeHookPayload, parseFormEncodedBody, + preparePendingGrokResultDiscovery, readRequestBody, resolveHookSource, writeEndpointFile, type HookListenerState } from './agent-hook-listener' +import { + clearGrokSessionPathLookupCacheForTests, + findGrokChatHistoryBySessionId +} from './grok-session-paths' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -51,6 +56,7 @@ describe('shared agent-hook-listener', () => { }) afterEach(() => { + clearGrokSessionPathLookupCacheForTests() vi.unstubAllEnvs() }) @@ -1420,6 +1426,18 @@ describe('shared agent-hook-listener', () => { ).toBe(false) }) + it('lets Copilot Stop consume a generic message without scheduling transcript retry', () => { + expect( + hasPendingAgentResultText('copilot', { + payload: { + hookEventName: 'Stop', + message: 'Copilot final response', + transcript_path: '/tmp/copilot-transcript.jsonl' + } + }) + ).toBe(false) + }) + it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => { const prompt = normalizeHookPayload( state, @@ -1463,6 +1481,141 @@ describe('shared agent-hook-listener', () => { }) }) + it('previews Grok-native tool names (run_terminal_command / search_replace)', () => { + const shell = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'git status' } + } + }, + 'production' + ) + const edit = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + toolName: 'search_replace', + toolInput: { path: 'src/app.ts', old_string: 'a', new_string: 'b' } + } + }, + 'production' + ) + expect(shell?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'run_terminal_command', + toolInput: 'git status' + }) + expect(edit?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'search_replace', + toolInput: 'src/app.ts' + }) + }) + + it('maps Grok ask_user_question PreToolUse to waiting with interactivePrompt', () => { + const questions = [ + { + question: 'Ship to which region?', + options: [{ label: 'us-east', description: 'US East' }] + } + ] + const waiting = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + tool_name: 'ask_user_question', + tool_input: { questions } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use', + toolName: 'ask_user_question', + toolInput: { questions }, + toolResponse: { selected: ['us-east'] } + } + }, + 'production' + ) + expect(waiting?.payload).toMatchObject({ + agentType: 'grok', + state: 'waiting', + toolName: 'ask_user_question' + }) + expect(waiting?.payload.interactivePrompt).toContain('Ship to which region?') + expect(answered?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'ask_user_question' + }) + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + + it('does not recreate a Grok question card on post_tool_use_failure', () => { + const event = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use_failure', + toolName: 'ask_user_question', + toolInput: { questions: [{ question: 'Retry?', options: ['yes', 'no'] }] }, + error: 'cancelled' + } + }, + 'production' + ) + + expect(event?.payload.state).toBe('working') + expect(event?.payload.interactivePrompt).toBeUndefined() + }) + + it('maps Grok StopFailure to done', () => { + normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'UserPromptSubmit', prompt: 'do work' } + }, + 'production' + ) + const done = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'StopFailure', error: 'api timeout' } + }, + 'production' + ) + expect(done?.payload).toMatchObject({ + agentType: 'grok', + state: 'done', + prompt: 'do work' + }) + }) + it('strips Grok internal user_query wrapper before caching the prompt', () => { const prompt = normalizeHookPayload( state, @@ -1600,7 +1753,7 @@ describe('shared agent-hook-listener', () => { expect(event).toBeNull() }) - it('reads Grok final assistant text from chat history on Stop', () => { + it('enriches Grok Stop from chat history despite a generic status message', () => { const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-session-')) const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528' const cwd = join(tmpDir, 'workspace') @@ -1624,18 +1777,82 @@ describe('shared agent-hook-listener', () => { 'production' ) + const body = { + paneKey: PANE_KEY, + payload: { hookEventName: 'Stop', sessionId, cwd, message: 'Session completed' } + } + expect(hasPendingAgentResultText('grok', body)).toBe(true) + const done = normalizeHookPayload(state, 'grok', body, 'production') + + expect(done?.payload.state).toBe('done') + expect(done?.payload.lastAssistantMessage).toBe('Hi! How can I help you today?') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('uses the hook envelope Grok home instead of the listener service environment', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-envelope-home-')) + const serviceGrokHome = join(tmpDir, 'service-grok') + const hookGrokHome = join(tmpDir, 'hook-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf529' + const cwd = join(tmpDir, 'workspace') + const sessionDir = join(hookGrokHome, 'sessions', encodeURIComponent(cwd), sessionId) + try { + vi.stubEnv('GROK_HOME', serviceGrokHome) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'from effective Grok home' })}\n` + ) + const done = normalizeHookPayload( state, 'grok', { paneKey: PANE_KEY, + grokHome: hookGrokHome, payload: { hookEventName: 'Stop', sessionId, cwd } }, 'production' ) - expect(done?.payload.state).toBe('done') - expect(done?.payload.lastAssistantMessage).toBe('Hi! How can I help you today?') + expect(done?.payload.lastAssistantMessage).toBe('from effective Grok home') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it.each([ + 'relative/grok-home', + join(tmpdir(), 'x'.repeat(4096)), + `${join(tmpdir(), 'grok-home')}\ninvalid` + ])('ignores invalid hook-envelope Grok home %s', (grokHome) => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-invalid-home-')) + const serviceGrokHome = join(tmpDir, 'service-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf530' + const cwd = join(tmpDir, 'workspace') + const sessionDir = join(serviceGrokHome, 'sessions', encodeURIComponent(cwd), sessionId) + try { + vi.stubEnv('GROK_HOME', serviceGrokHome) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'fallback result' })}\n` + ) + + const done = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + grokHome, + payload: { hookEventName: 'Stop', sessionId, cwd } + }, + 'production' + ) + + expect(done?.payload.lastAssistantMessage).toBe('fallback result') } finally { rmSync(tmpDir, { recursive: true, force: true }) } @@ -1683,6 +1900,50 @@ describe('shared agent-hook-listener', () => { ).toBe(true) }) + it('enriches a long-cwd Grok result after async discovery completes', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-pending-home-')) + const hookGrokHome = join(tmpDir, 'hook-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf531' + const cwd = `/${'long-workspace/'.repeat(30)}` + const sessionDir = join(hookGrokHome, 'sessions', 'workspace-slug', sessionId) + try { + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'from slug session' })}\n` + ) + + const body = { + paneKey: PANE_KEY, + grokHome: hookGrokHome, + payload: { hookEventName: 'SessionEnd', sessionId, cwd } + } + const discovery = preparePendingGrokResultDiscovery('grok', body) + expect(discovery).not.toBeNull() + await discovery + await expect( + findGrokChatHistoryBySessionId(join(hookGrokHome, 'sessions'), sessionId) + ).resolves.toBe(join(sessionDir, 'chat_history.jsonl')) + + const done = normalizeHookPayload(state, 'grok', body, 'production') + expect(done?.payload.lastAssistantMessage).toBe('from slug session') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('does not schedule Grok discovery for an invalid session id', () => { + expect( + hasPendingAgentResultText('grok', { + payload: { + hookEventName: 'SessionEnd', + sessionId: '../escape', + cwd: '/tmp/workspace' + } + }) + ).toBe(false) + }) + it('normalizes Hermes pre_llm_call to a working turn with prompt text', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 1619e4bd7ec..1513f117fbb 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -25,7 +25,7 @@ import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' @@ -36,6 +36,15 @@ import { } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { + buildGrokChatHistoryPathCandidates, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + GROK_SESSION_ID_MAX_LENGTH, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokSessionsDir +} from './grok-session-paths' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -492,11 +501,19 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { exec_command: ['cmd', 'command'], shell_command: ['cmd', 'command'], run_terminal_cmd: ['command'], + // Why: Grok maps Bash/Edit/Write to snake_case first-party tool names + // (run_terminal_command, search_replace, …). Without these keys the status + // row shows a blank toolInput for the bulk of Grok tool turns. + run_terminal_command: ['command'], + search_replace: ['file_path', 'path', 'filePath'], + write_to_file: ['TargetFile', 'path', 'file_path'], execute_code: ['code', 'command', 'cmd'], apply_patch: ['path', 'file_path'], view_image: ['path', 'file_path'], AskUser: ['question', 'prompt', 'message'], ask_user: ['question', 'prompt', 'message'], + AskUserQuestion: ['questions', 'question', 'prompt', 'message'], + ask_user_question: ['questions', 'question', 'prompt', 'message'], bash: ['command'], powershell: ['command'], create: ['path', 'file_path'], @@ -517,7 +534,6 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { skill_manage: ['action', 'name', 'file_path'], delegate_task: ['task', 'prompt', 'description'], view_file: ['AbsolutePath', 'path', 'file_path'], - write_to_file: ['TargetFile', 'path', 'file_path'], replace_file_content: ['TargetFile', 'path', 'file_path'], multi_replace_file_content: ['TargetFile', 'path', 'file_path'], list_dir: ['DirectoryPath', 'path'], @@ -528,7 +544,9 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { manage_task: ['TaskId', 'Action'], schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], ask_question: ['question', 'questions'], - ask_permission: ['Action', 'Target', 'Reason'] + ask_permission: ['Action', 'Target', 'Reason'], + spawn_subagent: ['prompt', 'description', 'subagent_type'], + open_page: ['url'] } const FALLBACK_TOOL_INPUT_KEYS = [ @@ -673,14 +691,14 @@ function deriveInteractivePrompt( toolInput: unknown, eventName?: unknown ): string | undefined { - // Why: an AskUserQuestion is pending only on the Pre/Permission event. On - // PostToolUse it has already been answered, so re-asserting the `{questions}` - // prompt would re-show an answered card instead of letting it clear. The live - // working indicator keys off agentStatus.state (not this prompt), so dropping - // it here doesn't suppress it. + // Why: providers vary event casing; any post-tool event means the question is + // no longer pending and must not recreate its answered live card. + const normalizedEventName = normalizeHookEventName(eventName) + const isPostToolEvent = + normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' if ( isAskUserQuestionTool(toolName) && - eventName !== 'PostToolUse' && + !isPostToolEvent && toolInput !== undefined && toolInput !== null ) { @@ -761,8 +779,8 @@ const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 const AMP_THREAD_ID_MAX_LENGTH = 256 const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 -const GROK_SESSION_ID_MAX_LENGTH = 128 const GROK_SESSION_CWD_MAX_LENGTH = 4096 +const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 function extractAssistantTextFromLine(line: string): string | undefined { let entry: unknown @@ -1031,38 +1049,92 @@ function readBoundedString( return value && value.length <= maxLength ? value : undefined } -function isSafeGrokSessionId(sessionId: string): boolean { - return /^[A-Za-z0-9_-]+$/.test(sessionId) && sessionId.length <= GROK_SESSION_ID_MAX_LENGTH +function readGrokHomeEnvelope(record: Record): string | undefined { + const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) + if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { + return undefined + } + return value } -function getGrokChatHistoryPath(hookPayload: Record): string | undefined { +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +type GrokSessionMetadata = { + sessionId: string + cwd?: string + sessionsDir: string +} + +function readGrokSessionMetadata( + hookPayload: Record, + grokHome?: string +): GrokSessionMetadata | undefined { const sessionId = readBoundedString( hookPayload, ['sessionId', 'session_id'], GROK_SESSION_ID_MAX_LENGTH ) + if (!sessionId || !isSafeGrokSessionId(sessionId)) { + return undefined + } const cwd = readBoundedString( hookPayload, ['cwd', 'workspaceRoot', 'workspace_root'], GROK_SESSION_CWD_MAX_LENGTH ) - if (!sessionId || !cwd || !isSafeGrokSessionId(sessionId)) { + // Why: hook scripts report the effective per-PTY/remote home; old scripts + // fall back to the listener runtime's Grok home for compatibility. + const sessionsDir = grokHome + ? join(grokHome, 'sessions') + : resolveGrokSessionsDir(process.env, homedir()) + return { sessionId, cwd, sessionsDir } +} + +function getGrokChatHistoryPath( + hookPayload: Record, + grokHome?: string +): string | undefined { + const metadata = readGrokSessionMetadata(hookPayload, grokHome) + if (!metadata) { return undefined } - return join( - homedir(), - '.grok', - 'sessions', - encodeURIComponent(cwd), - sessionId, - 'chat_history.jsonl' + const resolved = resolveGrokChatHistoryPathSync({ + sessionId: metadata.sessionId, + cwd: metadata.cwd ?? null, + sessionsDir: metadata.sessionsDir + }) + if (resolved) { + return resolved + } + const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) + if (cached) { + return cached + } + // Why: hasPendingAgentResultText only needs a plausible on-disk target when + // the file may not exist yet (SessionEnd can race the last write). Prefer a + // short-cwd candidate when available; async discovery caches slug groups. + if (!metadata.cwd) { + return undefined + } + return ( + buildGrokChatHistoryPathCandidates({ + sessionId: metadata.sessionId, + cwd: metadata.cwd, + sessionsDir: metadata.sessionsDir + })[0] ?? undefined ) } function readLastAssistantFromGrokChatHistory( - hookPayload: Record + hookPayload: Record, + grokHome?: string ): string | undefined { - const chatHistoryPath = getGrokChatHistoryPath(hookPayload) + const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) if (!chatHistoryPath) { return undefined } @@ -1076,12 +1148,15 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown if (!record) { return false } - const directMessage = - record.last_assistant_message ?? record.lastAssistantMessage ?? record.message - if (typeof directMessage === 'string' && directMessage.trim().length > 0) { + if (hasExplicitLastAssistantResult(record)) { return false } if (source === 'copilot') { + // Why: Copilot Stop consumes generic `message` as its final assistant text; + // Grok and Antigravity use that field for status text instead. + if (hasNonEmptyString(record.message)) { + return false + } const transcriptPath = record.transcript_path ?? record.transcriptPath return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 } @@ -1097,15 +1172,61 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown const transcriptPath = record.transcriptPath ?? record.transcript_path return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 } - if ( - source === 'grok' && - isGrokEvent(record.hookEventName ?? record.hook_event_name, 'stop', 'session_end') - ) { - return getGrokChatHistoryPath(record) !== undefined + const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) + if (pendingGrokDiscovery) { + void pendingGrokDiscovery + return true } return false } +function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +function hasExplicitLastAssistantResult(record: Record): boolean { + return ( + hasNonEmptyString(record.last_assistant_message) || + hasNonEmptyString(record.lastAssistantMessage) + ) +} + +/** Start bounded discovery only for a Grok completion that still needs result text. */ +export function preparePendingGrokResultDiscovery( + source: AgentHookSource, + body: unknown +): Promise | null { + if (source !== 'grok') { + return null + } + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record || hasExplicitLastAssistantResult(record)) { + return null + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (!isGrokEvent(eventName, 'stop', 'session_end')) { + return null + } + const metadata = readGrokSessionMetadata( + record, + envelope ? readGrokHomeEnvelope(envelope) : undefined + ) + if (!metadata) { + return null + } + // Why: the server can await this signal without moving filesystem discovery + // back into the synchronous hook normalization path. + return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( + () => undefined + ) +} + function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) } @@ -1842,20 +1963,24 @@ function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolea function extractGrokToolFields( eventName: unknown, - hookPayload: Record + hookPayload: Record, + grokHome?: string ): ToolSnapshot { if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { const toolName = readString(hookPayload, 'toolName') ?? readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments const toolInput = - deriveToolInputPreview(toolName, hookPayload.toolInput) ?? - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse + // (not PermissionRequest). Capture the full question payload so the live + // card path can render options instead of only a waiting Notification. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, + { toolName, toolInput, interactivePrompt }, { hasToolInputField: hasAnyOwnField(hookPayload, [ 'toolInput', @@ -1879,7 +2004,7 @@ function extractGrokToolFields( } return update } - if (isGrokEvent(eventName, 'stop', 'session_end')) { + if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { const direct = readString(hookPayload, 'lastAssistantMessage') ?? readString(hookPayload, 'last_assistant_message') @@ -1892,7 +2017,7 @@ function extractGrokToolFields( if (fromTranscript) { return { lastAssistantMessage: fromTranscript } } - const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload) + const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) if (fromChatHistory) { return { lastAssistantMessage: fromChatHistory } } @@ -2115,7 +2240,8 @@ function hasExplicitUserPrompt( function extractToolFields( source: AgentHookSource, eventName: unknown, - hookPayload: Record + hookPayload: Record, + options?: { grokHome?: string } ): ToolSnapshot { // Why: exhaustive switch so adding a source to AgentHookSource fails // typecheck here instead of silently routing through OpenCode's extractor. @@ -2145,7 +2271,7 @@ function extractToolFields( case 'command-code': return extractCommandCodeToolFields(eventName, hookPayload) case 'grok': - return extractGrokToolFields(eventName, hookPayload) + return extractGrokToolFields(eventName, hookPayload, options?.grokHome) case 'copilot': return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) case 'hermes': @@ -3013,7 +3139,8 @@ function normalizeGrokEvent( eventName: unknown, promptText: string, paneKey: string, - hookPayload: Record + hookPayload: Record, + grokHome?: string ): ParsedAgentStatusPayload | null { if (isGrokEvent(eventName, 'session_start')) { // Why: Grok emits SessionStart when the TUI opens/resumes. It should reset @@ -3026,18 +3153,25 @@ function normalizeGrokEvent( const notificationMessage = readString(hookPayload, 'message') const notificationType = getGrokNotificationType(hookPayload) const notificationLevel = readString(hookPayload, 'level') + const preToolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + // Why: Grok's ask_user_question is auto-allowed, so it emits PreToolUse while + // blocked on a human answer (same shape as Kimi). Map that to waiting so the + // sidebar attention state matches Claude PermissionRequest UX. + const isUserInputPreTool = + isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) + let stateName: 'working' | 'waiting' | 'done' | null = null if ( - isGrokEvent( - eventName, - 'user_prompt_submit', - 'pre_tool_use', - 'post_tool_use', - 'post_tool_use_failure' - ) + isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || + (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) ) { stateName = 'working' - } else if (isGrokEvent(eventName, 'stop', 'session_end')) { + } else if (isUserInputPreTool) { + stateName = 'waiting' + } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { stateName = 'done' } else if ( isGrokEvent(eventName, 'notification') && @@ -3066,7 +3200,7 @@ function normalizeGrokEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('grok', eventName, hookPayload), + extractToolFields('grok', eventName, hookPayload, { grokHome }), { resetOnNewTurn: isNewTurnEvent('grok', eventName) } ) @@ -3300,7 +3434,14 @@ export function normalizeHookPayload( ) break case 'grok': - payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + payload = normalizeGrokEvent( + state, + eventName, + promptText, + paneKey, + hookPayloadRecord, + readGrokHomeEnvelope(record) + ) break case 'copilot': payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayloadRecord) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7e0aef0cda9..d051d0500bb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -261,6 +261,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { windowBackgroundBlur: false, minimizeToTrayOnClose: false, terminalClipboardOnSelect: false, + // Why: OSC 52 is a classic data-exfiltration vector (any process piping + // untrusted output into the terminal can rewrite the clipboard). Keep the + // conservative default off; users who need Grok/tmux/nvim remote copy can + // enable the toggle. OSC 52 *query* remains disabled separately. terminalAllowOsc52Clipboard: false, claudeAgentTeamsMode: 'off', setupScriptLaunchMode: 'new-tab', diff --git a/src/shared/grok-session-path-lookup-queue.ts b/src/shared/grok-session-path-lookup-queue.ts new file mode 100644 index 00000000000..4809b314ca0 --- /dev/null +++ b/src/shared/grok-session-path-lookup-queue.ts @@ -0,0 +1,158 @@ +import { resolve } from 'node:path' + +export const GROK_SESSION_PATH_CACHE_MAX_ENTRIES = 64 +export const GROK_SESSION_SCAN_ACTIVE_ROOT_MAX = 4 +export const GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES = 64 + +export type GrokSessionPathScanner = ( + sessionsDir: string, + sessionId: string, + maxGroupEntries: number +) => Promise + +type PendingLookup = { + key: string + rootKey: string + sessionsDir: string + sessionId: string + maxGroupEntries: number + resolve: (path: string | null) => void +} + +export class GrokSessionPathLookupQueue { + private readonly successfulPaths = new Map() + private readonly inflight = new Map>() + private readonly activeRoots = new Set() + private readonly pending: PendingLookup[] = [] + private scanner: GrokSessionPathScanner + + constructor(private readonly defaultScanner: GrokSessionPathScanner) { + this.scanner = defaultScanner + } + + getCached(sessionsDir: string, sessionId: string): string | null { + const key = this.lookupKey(sessionsDir, sessionId) + const cached = this.successfulPaths.get(key) + if (!cached) { + return null + } + this.successfulPaths.delete(key) + this.successfulPaths.set(key, cached) + return cached + } + + find(sessionsDir: string, sessionId: string, maxGroupEntries: number): Promise { + const key = this.lookupKey(sessionsDir, sessionId) + const cached = this.getCached(sessionsDir, sessionId) + if (cached) { + return Promise.resolve(cached) + } + const existing = this.inflight.get(key) + if (existing) { + return existing + } + const rootKey = this.rootKey(sessionsDir) + let resolveLookup: (path: string | null) => void = () => undefined + const lookup = new Promise((resolvePromise) => { + resolveLookup = resolvePromise + }) + const pending = { + key, + rootKey, + sessionsDir, + sessionId, + maxGroupEntries, + resolve: resolveLookup + } + if (this.mustQueue(rootKey)) { + if (this.pending.length >= GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES) { + return Promise.resolve(null) + } + this.inflight.set(key, lookup) + this.pending.push(pending) + this.drain() + return lookup + } + this.inflight.set(key, lookup) + this.start(pending) + return lookup + } + + clearForTests(): void { + this.successfulPaths.clear() + this.inflight.clear() + this.activeRoots.clear() + for (const pending of this.pending.splice(0)) { + pending.resolve(null) + } + this.scanner = this.defaultScanner + } + + setScannerForTests(scanner: GrokSessionPathScanner): void { + this.scanner = scanner + } + + private rootKey(sessionsDir: string): string { + const root = resolve(sessionsDir) + return process.platform === 'win32' ? root.toLowerCase() : root + } + + private lookupKey(sessionsDir: string, sessionId: string): string { + return `${this.rootKey(sessionsDir)}\0${sessionId}` + } + + private mustQueue(rootKey: string): boolean { + return ( + this.pending.length > 0 || + this.activeRoots.has(rootKey) || + this.activeRoots.size >= GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + ) + } + + private cache(key: string, path: string): void { + this.successfulPaths.delete(key) + this.successfulPaths.set(key, path) + while (this.successfulPaths.size > GROK_SESSION_PATH_CACHE_MAX_ENTRIES) { + const oldest = this.successfulPaths.keys().next().value + if (typeof oldest !== 'string') { + return + } + this.successfulPaths.delete(oldest) + } + } + + private start(pending: PendingLookup): void { + this.activeRoots.add(pending.rootKey) + void (async () => { + try { + const path = await this.scanner( + pending.sessionsDir, + pending.sessionId, + pending.maxGroupEntries + ) + if (path) { + this.cache(pending.key, path) + } + pending.resolve(path) + } catch { + pending.resolve(null) + } finally { + this.activeRoots.delete(pending.rootKey) + this.inflight.delete(pending.key) + this.drain() + } + })() + } + + private drain(): void { + while (this.pending.length > 0 && this.activeRoots.size < GROK_SESSION_SCAN_ACTIVE_ROOT_MAX) { + const next = this.pending[0] + // Why: strict FIFO avoids starving repeated lookups for one sessions root. + if (this.activeRoots.has(next.rootKey)) { + return + } + this.pending.shift() + this.start(next) + } + } +} diff --git a/src/shared/grok-session-paths.test.ts b/src/shared/grok-session-paths.test.ts new file mode 100644 index 00000000000..90d4cf69e1e --- /dev/null +++ b/src/shared/grok-session-paths.test.ts @@ -0,0 +1,387 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + GROK_ENCODED_CWD_DIR_MAX_BYTES, + GROK_SESSION_SCAN_ACTIVE_ROOT_MAX, + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES, + buildGrokChatHistoryPathCandidates, + clearGrokSessionPathLookupCacheForTests, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + grokEncodedCwdDirName, + isGrokChatHistoryPath, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokHomeDir, + resolveGrokSessionsDir, + setGrokSessionDirectoryOpenerForTests, + setGrokSessionPathScannerForTests +} from './grok-session-paths' + +const tempDirs: string[] = [] + +afterEach(() => { + clearGrokSessionPathLookupCacheForTests() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function makeRoot(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-grok-session-paths-')) + tempDirs.push(dir) + return dir +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} { + let resolvePromise: (value: T) => void = () => undefined + let rejectPromise: (error: Error) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +describe('grok-session-paths', () => { + it('honors GROK_HOME for home and sessions roots', () => { + const root = makeRoot() + expect(resolveGrokHomeDir({ GROK_HOME: root }, '/unused')).toBe(root) + expect(resolveGrokSessionsDir({ GROK_HOME: root }, '/unused')).toBe(join(root, 'sessions')) + expect(resolveGrokHomeDir({}, '/home/ada')).toBe(join('/home/ada', '.grok')) + }) + + it('refuses to invent encodeURIComponent names longer than 255 bytes', () => { + const longCwd = `/${'a'.repeat(200)}/${'b'.repeat(200)}` + expect(Buffer.byteLength(encodeURIComponent(longCwd), 'utf8')).toBeGreaterThan( + GROK_ENCODED_CWD_DIR_MAX_BYTES + ) + expect(grokEncodedCwdDirName(longCwd)).toBeNull() + expect( + buildGrokChatHistoryPathCandidates({ + sessionId: 'sess-1', + cwd: longCwd, + sessionsDir: '/tmp/sessions' + }) + ).toEqual([]) + }) + + it('rejects unsafe session ids and path-special cwd components', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const invalidIds = ['../escape', 'nested/id', 'nested\\id', '.', '..', 'x'.repeat(129)] + + for (const sessionId of invalidIds) { + expect(isSafeGrokSessionId(sessionId)).toBe(false) + expect(buildGrokChatHistoryPathCandidates({ sessionId, cwd: '/repo', sessionsDir })).toEqual( + [] + ) + await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBeNull() + expect(isGrokChatHistoryPath('/repo/chat_history.jsonl', sessionId)).toBe(false) + } + + expect(isSafeGrokSessionId('019e37f4-5135-7b63-a4ab-6d13aa6bf528')).toBe(true) + expect(grokEncodedCwdDirName('.')).toBeNull() + expect(grokEncodedCwdDirName('..')).toBeNull() + expect( + buildGrokChatHistoryPathCandidates({ sessionId: 'safe-id', cwd: '..', sessionsDir }) + ).toEqual([]) + }) + + it('resolves via encodeURIComponent(cwd) when the short path exists', () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const cwd = '/tmp/work' + const sessionId = 'sess-short' + const history = join(sessionsDir, encodeURIComponent(cwd), sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{"type":"user"}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + cwd, + sessionsDir + }) + ).toBe(history) + }) + + it('does not synchronously discover a long-cwd slug group', () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const sessionId = 'sess-long' + // Simulate Grok's slug+hash group directory (not encodeURIComponent of cwd). + const slugGroup = 'long-path-ab12cd34' + const history = join(sessionsDir, slugGroup, sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(join(sessionsDir, slugGroup, '.cwd'), '/very/long/path\n') + writeFileSync(history, '{"type":"assistant","content":"hi"}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + cwd: `/${'x'.repeat(300)}`, + sessionsDir + }) + ).toBeNull() + }) + + it('finds the documented group/session layout asynchronously', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-async', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-async')).resolves.toBe(history) + }) + + it('deduplicates concurrent async discovery', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-deduped', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + const first = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped') + const second = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped') + + expect(second).toBe(first) + await expect(first).resolves.toBe(history) + }) + + it('reuses a successful discovery from cache without another scan', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-cached', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history) + rmSync(join(sessionsDir, 'slug-hash'), { recursive: true, force: true }) + + expect(getCachedGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).toBe(history) + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history) + }) + + it('does not descend below the documented group/session layout', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const nestedDecoy = join( + sessionsDir, + 'group', + 'other-session', + 'cache', + 'sess-target', + 'chat_history.jsonl' + ) + mkdirSync(dirname(nestedDecoy), { recursive: true }) + writeFileSync(nestedDecoy, '{}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId: 'sess-target', + cwd: '/group', + sessionsDir + }) + ).toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-target')).resolves.toBeNull() + }) + + it('applies the hard group-entry bound to the filesystem iteration subset', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + for (const group of ['a-group', 'b-group']) { + mkdirSync(join(sessionsDir, group), { recursive: true }) + } + const beyondBound = join(sessionsDir, 'z-target-group', 'sess-bounded', 'chat_history.jsonl') + mkdirSync(dirname(beyondBound), { recursive: true }) + writeFileSync(beyondBound, '{}\n') + setGrokSessionDirectoryOpenerForTests(async () => ({ + async *[Symbol.asyncIterator]() { + for (const name of ['a-group', 'b-group', 'z-target-group']) { + yield { + name, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + }, + async close() {} + })) + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 2)).resolves.toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 3)).resolves.toBe( + beyondBound + ) + }) + + it('stops the directory iterator after the exact eligible-entry cap and closes it', async () => { + let yielded = 0 + let closed = 0 + const entries = [ + { name: 'file', directory: false, symlink: false }, + { name: 'group-a', directory: true, symlink: false }, + { name: 'group-b', directory: true, symlink: false }, + { name: 'group-c', directory: true, symlink: false } + ] + setGrokSessionDirectoryOpenerForTests(async () => ({ + async *[Symbol.asyncIterator]() { + for (const entry of entries) { + yielded += 1 + yield { + name: entry.name, + isDirectory: () => entry.directory, + isSymbolicLink: () => entry.symlink + } + } + }, + async close() { + closed += 1 + } + })) + + await expect( + findGrokChatHistoryBySessionId('/missing/sessions', 'sess-iterator', 2) + ).resolves.toBeNull() + + expect(yielded).toBe(3) + expect(closed).toBe(1) + }) + + it('deduplicates the exact promise while a scanner is deferred', async () => { + const scan = deferred() + let calls = 0 + setGrokSessionPathScannerForTests(async () => { + calls += 1 + return scan.promise + }) + + const first = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same') + const second = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same') + expect(second).toBe(first) + expect(calls).toBe(1) + + scan.resolve(null) + await expect(first).resolves.toBeNull() + }) + + it('runs at most one scan per sessions root', async () => { + const scans = new Map>>() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (root, sessionId) => { + const key = `${root}:${sessionId}` + started.push(key) + const scan = deferred() + scans.set(key, scan) + return scan.promise + }) + + const first = findGrokChatHistoryBySessionId('/sessions/root', 'sess-1') + const second = findGrokChatHistoryBySessionId('/sessions/root', 'sess-2') + expect(started).toEqual(['/sessions/root:sess-1']) + + scans.get('/sessions/root:sess-1')?.resolve(null) + await first + expect(started).toEqual(['/sessions/root:sess-1', '/sessions/root:sess-2']) + scans.get('/sessions/root:sess-2')?.resolve(null) + await second + }) + + it('caps active roots globally and drains queued roots in FIFO order', async () => { + const scans = new Map>>() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (root) => { + started.push(root) + const scan = deferred() + scans.set(root, scan) + return scan.promise + }) + const roots = Array.from( + { length: GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 2 }, + (_, index) => `/sessions/root-${index}` + ) + const lookups = roots.map((root, index) => + findGrokChatHistoryBySessionId(root, `sess-${index}`) + ) + + expect(started).toEqual(roots.slice(0, GROK_SESSION_SCAN_ACTIVE_ROOT_MAX)) + scans.get(roots[0])?.resolve(null) + await lookups[0] + expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX]) + scans.get(roots[1])?.resolve(null) + await lookups[1] + expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 1]) + + for (const root of roots.slice(2)) { + scans.get(root)?.resolve(null) + } + await Promise.all(lookups) + }) + + it('resolves queue overflow to null and drains the bounded queue', async () => { + const firstScan = deferred() + let calls = 0 + setGrokSessionPathScannerForTests(async () => { + calls += 1 + return calls === 1 ? firstScan.promise : null + }) + const active = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-active') + const queued = Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, (_, index) => + findGrokChatHistoryBySessionId('/sessions/overflow', `sess-queued-${index}`) + ) + const overflow = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-overflow') + + await expect(overflow).resolves.toBeNull() + expect(calls).toBe(1) + firstScan.resolve(null) + await expect(active).resolves.toBeNull() + await expect(Promise.all(queued)).resolves.toEqual( + Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, () => null) + ) + expect(calls).toBe(1 + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES) + }) + + it('cleans up a rejected scan and starts the next queued lookup', async () => { + const firstScan = deferred() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (_root, sessionId) => { + started.push(sessionId) + if (sessionId === 'sess-reject') { + return firstScan.promise + } + return null + }) + const rejected = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-reject') + const next = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-next') + + firstScan.reject(new Error('scanner failed')) + await expect(rejected).resolves.toBeNull() + await expect(next).resolves.toBeNull() + expect(started).toEqual(['sess-reject', 'sess-next']) + }) + + it('does not synchronously scan sessions when cwd is absent', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const sessionId = 'sess-env' + const history = join(sessionsDir, encodeURIComponent('/repo'), sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + sessionsDir, + env: { GROK_HOME: root } + }) + ).toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBe(history) + }) +}) diff --git a/src/shared/grok-session-paths.ts b/src/shared/grok-session-paths.ts new file mode 100644 index 00000000000..e4d59c61f09 --- /dev/null +++ b/src/shared/grok-session-paths.ts @@ -0,0 +1,294 @@ +import { lstatSync } from 'node:fs' +import { lstat, opendir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + GrokSessionPathLookupQueue, + type GrokSessionPathScanner +} from './grok-session-path-lookup-queue' + +export { + GROK_SESSION_PATH_CACHE_MAX_ENTRIES, + GROK_SESSION_SCAN_ACTIVE_ROOT_MAX, + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES, + type GrokSessionPathScanner +} from './grok-session-path-lookup-queue' + +export const GROK_CHAT_HISTORY_FILE = 'chat_history.jsonl' +// Why: Grok URL-encodes the cwd for the sessions group directory. When that +// encoded name exceeds 255 bytes it switches to a slug+hash layout. +export const GROK_ENCODED_CWD_DIR_MAX_BYTES = 255 +export const GROK_SESSION_ID_MAX_LENGTH = 128 +// Why: session discovery runs in hook/main hot paths; one corrupt or enormous +// sessions root must not cause unbounded candidate probes. +export const GROK_SESSION_GROUP_SCAN_MAX_ENTRIES = 2_048 + +export type GrokSessionPathEnv = + | NodeJS.ProcessEnv + | Partial> + +/** Official ids are UUIDs; keep legacy/test token ids while rejecting paths. */ +export function isSafeGrokSessionId(sessionId: string): boolean { + return ( + sessionId.length > 0 && + sessionId.length <= GROK_SESSION_ID_MAX_LENGTH && + /^[A-Za-z0-9_-]+$/.test(sessionId) + ) +} + +/** Resolve Grok's configured home, falling back to the runtime user's home. */ +export function resolveGrokHomeDir( + env: GrokSessionPathEnv = process.env, + homeDir: string = homedir() +): string { + const fromEnv = env.GROK_HOME?.trim() + return fromEnv || join(homeDir, '.grok') +} + +export function resolveGrokSessionsDir( + env: GrokSessionPathEnv = process.env, + homeDir: string = homedir() +): string { + return join(resolveGrokHomeDir(env, homeDir), 'sessions') +} + +/** Return Grok's safe cwd-group component, or null for slug/invalid layouts. */ +export function grokEncodedCwdDirName(cwd: string): string | null { + const trimmed = cwd.trim() + if (!trimmed) { + return null + } + let encoded: string + try { + encoded = encodeURIComponent(trimmed) + } catch { + return null + } + // encodeURIComponent deliberately leaves dots untouched; reject path syntax. + if (encoded === '.' || encoded === '..' || encoded.includes('/') || encoded.includes('\\')) { + return null + } + return Buffer.byteLength(encoded, 'utf8') <= GROK_ENCODED_CWD_DIR_MAX_BYTES ? encoded : null +} + +/** Fast-path candidates when both session id and cwd are known. */ +export function buildGrokChatHistoryPathCandidates(args: { + sessionId: string + cwd?: string | null + sessionsDir: string +}): string[] { + const sessionId = args.sessionId.trim() + if (!isSafeGrokSessionId(sessionId)) { + return [] + } + const cwd = args.cwd?.trim() + if (!cwd) { + return [] + } + const encoded = grokEncodedCwdDirName(cwd) + if (!encoded) { + return [] + } + const candidate = join(args.sessionsDir, encoded, sessionId, GROK_CHAT_HISTORY_FILE) + return isPathWithin(args.sessionsDir, candidate) ? [candidate] : [] +} + +/** Resolve only the exact cwd candidate; safe for synchronous hook hot paths. */ +export function resolveGrokChatHistoryPathSync(args: { + sessionId: string + cwd?: string | null + sessionsDir?: string + env?: GrokSessionPathEnv + homeDir?: string +}): string | null { + const sessionId = args.sessionId.trim() + if (!isSafeGrokSessionId(sessionId)) { + return null + } + const sessionsDir = + args.sessionsDir ?? resolveGrokSessionsDir(args.env ?? process.env, args.homeDir ?? homedir()) + + for (const candidate of buildGrokChatHistoryPathCandidates({ + sessionId, + cwd: args.cwd, + sessionsDir + })) { + if (isSafeChatHistoryFileSync(sessionsDir, candidate)) { + return candidate + } + } + return null +} + +type GrokSessionDirectoryEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +type GrokSessionDirectory = AsyncIterable & { + close(): Promise +} + +type GrokSessionDirectoryOpener = (sessionsDir: string) => Promise + +const defaultSessionDirectoryOpener: GrokSessionDirectoryOpener = (sessionsDir) => + opendir(sessionsDir) +let sessionDirectoryOpener = defaultSessionDirectoryOpener +const sessionPathLookupQueue = new GrokSessionPathLookupQueue(scanGrokChatHistoryBySessionId) + +/** Read a previously resolved path without filesystem work (hook retry path). */ +export function getCachedGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string +): string | null { + const trimmedId = sessionId.trim() + if (!isSafeGrokSessionId(trimmedId)) { + return null + } + return sessionPathLookupQueue.getCached(sessionsDir, trimmedId) +} + +/** Async bounded lookup with inflight dedupe and a small successful-result cache. */ +export function findGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string, + maxGroupEntries = GROK_SESSION_GROUP_SCAN_MAX_ENTRIES +): Promise { + const trimmedId = sessionId.trim() + if (!isSafeGrokSessionId(trimmedId)) { + return Promise.resolve(null) + } + return sessionPathLookupQueue.find(sessionsDir, trimmedId, maxGroupEntries) +} + +async function scanGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string, + maxGroupEntries: number +): Promise { + const max = normalizeGroupEntryLimit(maxGroupEntries) + if (max === 0) { + return null + } + let directory: GrokSessionDirectory | undefined + try { + directory = await sessionDirectoryOpener(sessionsDir) + let eligibleEntries = 0 + // Why: the filesystem's iteration order defines the bounded subset; sorting + // would first materialize an unbounded sessions root on the main process. + for await (const entry of directory) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue + } + eligibleEntries += 1 + const history = join(sessionsDir, entry.name, sessionId, GROK_CHAT_HISTORY_FILE) + if (await isSafeChatHistoryFile(sessionsDir, history)) { + return history + } + if (eligibleEntries >= max) { + return null + } + } + return null + } catch { + return null + } finally { + if (directory) { + try { + await directory.close() + } catch { + // Async iteration closes Node Dir handles automatically; custom/test + // iterators still get the explicit close attempt above. + } + } + } +} + +/** Test isolation for the module-level inflight/success caches. */ +export function clearGrokSessionPathLookupCacheForTests(): void { + sessionPathLookupQueue.clearForTests() + sessionDirectoryOpener = defaultSessionDirectoryOpener +} + +function normalizeGroupEntryLimit(requestedMax: number): number { + return Math.min( + GROK_SESSION_GROUP_SCAN_MAX_ENTRIES, + Math.max(0, Math.floor(Number.isFinite(requestedMax) ? requestedMax : 0)) + ) +} + +/** Replace directory IO for bounded-iterator tests. */ +export function setGrokSessionDirectoryOpenerForTests(opener: GrokSessionDirectoryOpener): void { + sessionDirectoryOpener = opener +} + +/** Replace scan IO for queue/concurrency tests. */ +export function setGrokSessionPathScannerForTests(scanner: GrokSessionPathScanner): void { + sessionPathLookupQueue.setScannerForTests(scanner) +} + +function isPathWithin(root: string, candidate: string): boolean { + const rel = relative(resolve(root), resolve(candidate)) + return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) +} + +function candidateSessionDir(candidate: string): string { + return dirname(candidate) +} + +function isSafeChatHistoryFileSync(sessionsDir: string, candidate: string): boolean { + if (!isPathWithin(sessionsDir, candidate)) { + return false + } + try { + const sessionDir = candidateSessionDir(candidate) + const groupStat = lstatSync(dirname(sessionDir)) + const sessionStat = lstatSync(sessionDir) + const fileStat = lstatSync(candidate) + return ( + groupStat.isDirectory() && + !groupStat.isSymbolicLink() && + sessionStat.isDirectory() && + !sessionStat.isSymbolicLink() && + fileStat.isFile() && + !fileStat.isSymbolicLink() + ) + } catch { + return false + } +} + +async function isSafeChatHistoryFile(sessionsDir: string, candidate: string): Promise { + if (!isPathWithin(sessionsDir, candidate)) { + return false + } + try { + const sessionDir = candidateSessionDir(candidate) + const [groupStat, sessionStat, fileStat] = await Promise.all([ + lstat(dirname(sessionDir)), + lstat(sessionDir), + lstat(candidate) + ]) + return ( + groupStat.isDirectory() && + !groupStat.isSymbolicLink() && + sessionStat.isDirectory() && + !sessionStat.isSymbolicLink() && + fileStat.isFile() && + !fileStat.isSymbolicLink() + ) + } catch { + return false + } +} + +/** True when path looks like a Grok chat history under a safe session id. */ +export function isGrokChatHistoryPath(path: string, sessionId: string): boolean { + const trimmedId = sessionId.trim() + return ( + isSafeGrokSessionId(trimmedId) && + basename(path) === GROK_CHAT_HISTORY_FILE && + basename(dirname(path)) === trimmedId + ) +} diff --git a/src/shared/terminal-query-reply.test.ts b/src/shared/terminal-query-reply.test.ts index ee26abf1604..12c6c57d61b 100644 --- a/src/shared/terminal-query-reply.test.ts +++ b/src/shared/terminal-query-reply.test.ts @@ -1,3 +1,4 @@ +import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' import { isTerminalQueryReply } from './terminal-query-reply' @@ -36,6 +37,23 @@ describe('isTerminalQueryReply', () => { expect(isTerminalQueryReply('\x1bP>|xterm.js(5.6.0)\x1b\\')).toBe(true) }) + it('classifies the fully framed XTVERSION reply emitted by real xterm', async () => { + const terminal = new Terminal() + const replies: string[] = [] + const disposable = terminal.onData((data) => replies.push(data)) + try { + await new Promise((resolve) => terminal.write('\x1b[>q', resolve)) + expect(replies).toHaveLength(1) + const reply = replies[0] + expect(reply.startsWith('\x1bP>|xterm.js(')).toBe(true) + expect(reply.endsWith(')\x1b\\')).toBe(true) + expect(isTerminalQueryReply(reply)).toBe(true) + } finally { + disposable.dispose() + terminal.dispose() + } + }) + it('documents the accepted modified-F3/CPR collision', () => { // xterm.js encodes Shift+F3 as CSI 1;2R — byte-identical to a CPR report. // Classified as a reply on purpose: order is still preserved (the immediate diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index fc06ac4912b..ff4427bb5da 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -28,6 +28,8 @@ export type TuiAgentConfig = { launchCmdByPlatform?: Partial> expectedProcess: string promptInjectionMode: AgentPromptInjectionMode + /** Option terminator required before positional prompts that may look like CLI syntax. */ + argvPromptSeparator?: '--' /** Why: flag that launches the TUI with the given text already in the * input box but NOT submitted, so the user still gets a reviewable draft. * Only set when the CLI documents native support — e.g. Claude's @@ -345,7 +347,14 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'grok', launchCmd: 'grok', expectedProcess: 'grok', - promptInjectionMode: 'stdin-after-start' + // Why: Grok CLI accepts an initial prompt as a positional argv + // (`grok "fix the bug"`). Prefer argv over stdin-after-start so multi-line + // / special-character prompts are not typed as raw PTY keystrokes, and so + // clipboard-derived launch text is not mangled by line-edit shortcuts. + promptInjectionMode: 'argv', + // Why: prompts such as `help` or `--version` otherwise select Grok CLI + // syntax instead of starting an interactive turn with that literal text. + argvPromptSeparator: '--' }, devin: { detectCmd: 'devin', diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 5aef452c833..71f455fbc57 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -49,6 +49,63 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) + it('terminates Grok options before a flag-shaped POSIX prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '--version', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("grok -- '--version'") + }) + + it('terminates Grok options before a flag-shaped PowerShell prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '-h', + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("grok -- '-h'") + }) + + it('terminates Grok options before a subcommand-shaped cmd prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: 'help', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).toBe('grok -- "help"') + }) + + it('places the Grok prompt separator after configured agent arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '--version', + cmdOverrides: {}, + agentArgs: '--always-approve', + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("grok '--always-approve' -- '--version'") + }) + + it('does not add the Grok prompt separator to other argv agents', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '--version', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("codex '--version'") + }) + it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 30ca71132e5..924108f7a19 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -118,9 +118,10 @@ export function buildAgentStartupPlan(args: { const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) if (config.promptInjectionMode === 'argv') { + const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' return { agent, - launchCommand: `${baseCommand.command} ${quotedPrompt}`, + launchCommand: `${baseCommand.command}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, diff --git a/src/shared/types.ts b/src/shared/types.ts index 8cc2d2eef9c..75f23a7c306 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2597,8 +2597,8 @@ export type GlobalSettings = { * paste with Cmd/Ctrl+V without an intervening Cmd/Ctrl+Shift+C. Defaults * to false so existing users keep the explicit-copy behavior. */ terminalClipboardOnSelect: boolean - /** Why: lets TUIs like tmux, nvim, and fzf copy to the system clipboard via - * the OSC 52 escape sequence — essential for SSH-hosted workflows where + /** Why: lets TUIs like Grok, tmux, nvim, and fzf copy to the system clipboard + * via the OSC 52 escape sequence — essential for SSH-hosted workflows where * the terminal is the only bridge to the local clipboard. Defaults to * false because OSC 52 is a classic data-exfiltration vector (any * process piping untrusted output into the terminal — `cat attacker.log` From d9b1fbbc07bc53647622a7d004a48b61b0cfcd2e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:34:50 -0700 Subject: [PATCH 07/95] Fix Markdown URL actions behind Explorer (#8137) --- .../src/assets/rich-markdown-editor.css | 5 +- .../components/editor/RichMarkdownEditor.tsx | 5 + .../editor/RichMarkdownEditorSurface.tsx | 6 + .../editor/RichMarkdownLinkBubble.tsx | 178 +++++++++++++++++- .../github/GitHubMarkdownComposer.tsx | 5 + ...rich-markdown-link-bubble-stacking.spec.ts | 157 +++++++++++++++ 6 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/rich-markdown-link-bubble-stacking.spec.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 395b18131a7..ccf79ad3638 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1381,8 +1381,9 @@ /* ── Link Bubble ──────────────────────────────────────── */ .rich-markdown-link-bubble { - position: absolute; - z-index: 30; + position: fixed; + z-index: 60; + -webkit-app-region: no-drag; display: flex; align-items: center; gap: 2px; diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 990d2fdaf38..94e0835ea5a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -346,6 +346,7 @@ export default function RichMarkdownEditor({ { + setLinkBubble(null) + setIsEditingLink(false) + }, handleLinkSave, handleLinkRemove, handleLinkEditCancel, diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 92fe187e823..f5283616abb 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -37,6 +37,7 @@ function shouldFocusEmptyEditorFromSurfaceClick( type RichMarkdownEditorSurfaceProps = { editor: Editor | null editorFontZoomLevel: number + rootElement: HTMLDivElement | null rootRef: (node: HTMLDivElement | null) => void scrollContainerRef: React.RefObject headerSlot?: React.ReactNode @@ -91,6 +92,7 @@ type RichMarkdownEditorSurfaceProps = { toggleWholeWord: () => void } linkBubbleActions: { + dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void @@ -119,6 +121,7 @@ type RichMarkdownEditorSurfaceProps = { export function RichMarkdownEditorSurface({ editor, editorFontZoomLevel, + rootElement, rootRef, scrollContainerRef, headerSlot, @@ -249,8 +252,11 @@ export function RichMarkdownEditorSurface({ {linkBubble ? ( linkBubbleActions.setIsEditingLink(true)} diff --git a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx index 7a144d446d6..7ff8464cec3 100644 --- a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx +++ b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { Editor } from '@tiptap/react' import { ExternalLink, Pencil, Unlink } from 'lucide-react' import { translate } from '@/i18n/i18n' @@ -9,6 +10,66 @@ export type LinkBubbleState = { top: number } +const LINK_BUBBLE_VIEWPORT_MARGIN = 8 +const LINK_BUBBLE_MAX_WIDTH = 344 +const LINK_BUBBLE_HEIGHT = 40 +const LINK_BUBBLE_LAYOUT_ATTRIBUTES = ['aria-hidden', 'class', 'hidden', 'inert', 'style'] + +function hasRectChanged(initialRect: DOMRect, nextRect: DOMRect): boolean { + return ( + Math.abs(nextRect.left - initialRect.left) > 0.5 || + Math.abs(nextRect.top - initialRect.top) > 0.5 || + Math.abs(nextRect.width - initialRect.width) > 0.5 || + Math.abs(nextRect.height - initialRect.height) > 0.5 + ) +} + +function getStableAnchorClassName(anchorElement: HTMLElement): string { + return [...anchorElement.classList] + .filter((className) => className !== 'rich-markdown-mod-held') + .sort() + .join(' ') +} + +function isAnchorVisible(anchorElement: HTMLElement): boolean { + if (!anchorElement.isConnected || anchorElement.getClientRects().length === 0) { + return false + } + for (let element: HTMLElement | null = anchorElement; element; element = element.parentElement) { + const style = window.getComputedStyle(element) + if ( + element.hidden || + element.inert || + element.getAttribute('aria-hidden') === 'true' || + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' || + Number.parseFloat(style.opacity) === 0 + ) { + return false + } + } + return true +} + +function clampDocumentBubblePosition(linkBubble: LinkBubbleState): React.CSSProperties { + // Why: the body portal no longer inherits editor clipping, so keep every + // action reachable when the selected link sits at a window edge. + const maxLeft = Math.max( + LINK_BUBBLE_VIEWPORT_MARGIN, + window.innerWidth - LINK_BUBBLE_MAX_WIDTH - LINK_BUBBLE_VIEWPORT_MARGIN + ) + const maxTop = Math.max( + LINK_BUBBLE_VIEWPORT_MARGIN, + window.innerHeight - LINK_BUBBLE_HEIGHT - LINK_BUBBLE_VIEWPORT_MARGIN + ) + return { + position: 'fixed', + left: Math.min(Math.max(linkBubble.left, LINK_BUBBLE_VIEWPORT_MARGIN), maxLeft), + top: Math.min(Math.max(linkBubble.top, LINK_BUBBLE_VIEWPORT_MARGIN), maxTop) + } +} + export function getLinkBubblePosition( editor: Editor, rootEl: HTMLElement | null @@ -16,13 +77,12 @@ export function getLinkBubblePosition( const { from } = editor.state.selection try { const coords = editor.view.coordsAtPos(from) - const rootRect = rootEl?.getBoundingClientRect() - if (!rootRect) { + if (!rootEl) { return null } return { - left: coords.left - rootRect.left, - top: coords.bottom - rootRect.top + 4 + left: coords.left, + top: coords.bottom + 4 } } catch { return null @@ -91,28 +151,126 @@ function LinkEditInput({ } type RichMarkdownLinkBubbleProps = { + anchorElement: HTMLElement | null linkBubble: LinkBubbleState isEditing: boolean + onDismiss: () => void onSave: (href: string) => void onRemove: () => void onEditStart: () => void onEditCancel: () => void onOpen: () => void + portalToDocument?: boolean } export function RichMarkdownLinkBubble({ + anchorElement, linkBubble, isEditing, + onDismiss, onSave, onRemove, onEditStart, onEditCancel, - onOpen + onOpen, + portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { - return ( + const bubbleRef = useRef(null) + const onDismissRef = useRef(onDismiss) + onDismissRef.current = onDismiss + + useEffect(() => { + if (!anchorElement || !isAnchorVisible(anchorElement)) { + onDismissRef.current() + return + } + + const dismiss = (): void => onDismissRef.current() + const dismissOutside = (event: Event): void => { + const target = event.target + if ( + target instanceof Node && + !anchorElement.contains(target) && + !bubbleRef.current?.contains(target) + ) { + dismiss() + } + } + const initialRect = anchorElement.getBoundingClientRect() + const initialAnchorClassName = getStableAnchorClassName(anchorElement) + const dismissIfLayoutInvalidated = (mutations: MutationRecord[] = []): void => { + const anchorStyleChanged = mutations.some( + (mutation) => mutation.target === anchorElement && mutation.attributeName === 'style' + ) + if ( + !isAnchorVisible(anchorElement) || + hasRectChanged(initialRect, anchorElement.getBoundingClientRect()) || + getStableAnchorClassName(anchorElement) !== initialAnchorClassName || + anchorStyleChanged + ) { + dismiss() + } + } + const resizeObserver = new ResizeObserver(() => { + dismissIfLayoutInvalidated() + }) + const intersectionObserver = new IntersectionObserver(([entry]) => { + if (!entry?.isIntersecting || !isAnchorVisible(anchorElement)) { + dismiss() + } + }) + const mutationObserver = new MutationObserver(dismissIfLayoutInvalidated) + const dismissOnScroll = (event: Event): void => { + const target = event.target + // Why: long URL inputs scroll horizontally to keep the caret visible; + // only scrolling outside the bubble invalidates its document position. + if (target instanceof Node && bubbleRef.current?.contains(target)) { + return + } + dismiss() + } + + resizeObserver.observe(anchorElement) + intersectionObserver.observe(anchorElement) + for ( + let element: HTMLElement | null = anchorElement; + element; + element = element.parentElement + ) { + mutationObserver.observe(element, { + attributes: true, + attributeFilter: LINK_BUBBLE_LAYOUT_ATTRIBUTES + }) + } + window.addEventListener('pointerdown', dismissOutside, true) + window.addEventListener('focusin', dismissOutside, true) + window.addEventListener('scroll', dismissOnScroll, true) + window.addEventListener('resize', dismiss) + return () => { + resizeObserver.disconnect() + intersectionObserver.disconnect() + mutationObserver.disconnect() + window.removeEventListener('pointerdown', dismissOutside, true) + window.removeEventListener('focusin', dismissOutside, true) + window.removeEventListener('scroll', dismissOnScroll, true) + window.removeEventListener('resize', dismiss) + } + }, [anchorElement]) + + const anchorRect = anchorElement?.getBoundingClientRect() + const positionStyle: React.CSSProperties = portalToDocument + ? clampDocumentBubblePosition(linkBubble) + : { + position: 'absolute', + left: linkBubble.left - (anchorRect?.left ?? 0), + top: linkBubble.top - (anchorRect?.top ?? 0) + } + + const bubble = (
{ // Prevent editor blur when clicking bubble buttons, but let inputs // receive focus normally. @@ -166,4 +324,8 @@ export function RichMarkdownLinkBubble({ )}
) + + // Why: editor panes clip overflow at the workbench boundary, so the URL + // actions must portal to the app layer to remain above the right sidebar. + return portalToDocument ? createPortal(bubble, document.body) : bubble } diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 9a4019a3f8b..3159408be66 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -385,8 +385,13 @@ export function GitHubMarkdownComposer({ {attachmentFooter} {linkBubble ? ( { + setLinkBubble(null) + setIsEditingLink(false) + }} onSave={handleLinkSave} onRemove={handleLinkRemove} onEditStart={() => setIsEditingLink(true)} diff --git a/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts new file mode 100644 index 00000000000..f84691db5db --- /dev/null +++ b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts @@ -0,0 +1,157 @@ +/** + * Reliability contract: + * - Invariant: link actions remain visible and interactive when they cross the Explorer seam. + * - Failure source: the in-editor bubble was clipped by overflow-hidden workbench ancestors. + * - Oracle: a real Chromium hit test inside the bubble/Explorer overlap resolves to the bubble. + * - Layer: Electron is required because DOM shims cannot model clipping or stacking contexts. + * - Wait: visible editor, Explorer, link, and bubble locators; no timing sleeps. + * - Artifacts: Playwright retains a trace and screenshot on failure. + * - Maturity: experimental pending CI soak history. + */ + +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const LINK_HREF = 'https://example.com/a/very/long/path/that/makes/the-link-bubble-wide' +const MARKDOWN = `# Rich markdown link overlay repro + +This paragraph deliberately places the link near the right edge of the editor so its URL bubble reaches the Explorer boundary: alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau [hover this URL](${LINK_HREF}). +` + +type OverlapHitTest = { + bubbleRight: number + explorerLeft: number + overlapWidth: number + topElementIsBubble: boolean +} + +test.describe('Rich markdown link bubble stacking', () => { + test('link actions stay above the right Explorer', async ({ orcaPage }, testInfo) => { + await orcaPage.setViewportSize({ width: 1920, height: 1080 }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'link-bubble-stacking', + testInfo.workerIndex, + MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + store.setState({ + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarWidth: 780 + }) + }) + + const explorer = orcaPage.locator('[data-orca-explorer-shell]') + const link = orcaPage.locator(`.rich-markdown-editor a[href="${LINK_HREF}"]`) + await expect(explorer).toBeVisible() + await expect(link).toBeVisible() + + await link.click() + + const bubble = orcaPage.locator('.rich-markdown-link-bubble') + await expect(bubble).toBeVisible() + await expect(bubble.locator('.rich-markdown-link-url')).toContainText('https://example.com') + + const overlap = await orcaPage.evaluate((): OverlapHitTest => { + const bubble = document.querySelector('.rich-markdown-link-bubble') + const explorer = document.querySelector('[data-orca-explorer-shell]') + if (!bubble || !explorer) { + throw new Error('Link bubble or Explorer was not rendered') + } + + const bubbleRect = bubble.getBoundingClientRect() + const explorerRect = explorer.getBoundingClientRect() + const overlapLeft = Math.max(bubbleRect.left, explorerRect.left) + const overlapRight = Math.min(bubbleRect.right, explorerRect.right) + const overlapWidth = Math.max(0, overlapRight - overlapLeft) + const probeX = overlapLeft + Math.min(4, overlapWidth / 2) + const probeY = bubbleRect.top + bubbleRect.height / 2 + const topElement = document.elementFromPoint(probeX, probeY) + + return { + bubbleRight: bubbleRect.right, + explorerLeft: explorerRect.left, + overlapWidth, + topElementIsBubble: topElement !== null && bubble.contains(topElement) + } + }) + + expect(overlap.bubbleRight).toBeGreaterThan(overlap.explorerLeft) + expect(overlap.overlapWidth).toBeGreaterThan(8) + expect(overlap.topElementIsBubble).toBe(true) + + const editButton = bubble.getByTitle('Edit link') + const editButtonBounds = await editButton.boundingBox() + const explorerBounds = await explorer.boundingBox() + expect(editButtonBounds).not.toBeNull() + expect(explorerBounds).not.toBeNull() + expect(editButtonBounds!.x + editButtonBounds!.width).toBeGreaterThan(explorerBounds!.x) + await editButton.click() + const input = bubble.locator('input') + await expect(input).toBeFocused() + await input.fill(`https://example.com/${'long-url-segment/'.repeat(30)}`) + await input.press('End') + expect(await input.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0) + await expect(input).toBeFocused() + await expect(bubble).toBeVisible() + await orcaPage.keyboard.press('Escape') + await expect(bubble.locator('.rich-markdown-link-url')).toBeVisible() + + await explorer.getByPlaceholder('Find files').click() + await expect(bubble).toHaveCount(0) + + await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click() + await link.click() + await expect(bubble).toBeVisible() + const originalEditorZoom = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + const zoom = store.getState().editorFontZoomLevel + store.getState().setEditorFontZoomLevel(zoom + 1) + return zoom + }) + await expect(bubble).toHaveCount(0) + await orcaPage.evaluate((zoom) => { + window.__store?.getState().setEditorFontZoomLevel(zoom) + }, originalEditorZoom) + + await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click() + await link.click() + await expect(bubble).toBeVisible() + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + store.getState().setActiveView('settings') + }) + await expect(bubble).toHaveCount(0) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) From da2c692d31f99245615469127ccd33cc0dfd164f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:45:33 -0700 Subject: [PATCH 08/95] fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts (#8031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): forward Option hotkeys to kitty-keyboard TUIs on compose layouts On macOS layouts where Option composes characters (ABC and all non-US, the effective default), pressing Option+P in a TUI that negotiated the kitty keyboard protocol made xterm's kitty encoder report the composed codepoint (alt+pi, CSI 960;3u) instead of the physical key (alt+p, CSI 112;3u). No TUI binds the composed form, so agent hotkeys like OMP's Alt+P / Alt+M neither fired nor typed anything. Fix: mirror each pane's application-negotiated kitty flags with a scan-based tracker fed only from PTY output (immune to Orca's defensive renderer-side kitty resets on Ctrl+C interrupts and reattach), and have the terminal shortcut policy encode Option chords as kitty CSI-u from the physical key when the pane's app opted in. Dead keys stay exempt so Option composition still works, and panes without kitty negotiation (shells) are unchanged. Alt+Arrow / Alt+Backspace now defer to xterm's native kitty encoding in kitty panes instead of the legacy readline translations. The daemon's headless emulator tracks the same flags and re-arms them via the snapshot rehydrate preamble (CSI = flags ; 1 u), so the behavior survives window reloads and reattaches; PTY exit and cold restore reset the mirror. Validated byte-for-byte against a real omp 16.3.15 in a pty: the policy's emitted CSI-u opens the temporary-model selector (Alt+P) and agent hub (Alt+A), identical to the legacy ESC-prefixed forms it parses. * fix(terminal): harden kitty Option-chord mirror for soft resets, replay redelivery, and non-QWERTY layouts Three review findings on the kitty keyboard mirror, each verified against a live omp 16.3.15 pty session using the real production modules: - DECSTR: xterm's soft reset (CSI ! p) clears its kitty flags and stacks for both screens without switching buffers; the tracker now mirrors that, so a soft-resetting TUI stops receiving kitty-encoded Option chords. - Replay redelivery: relay reconnects can redeliver the retained replay window, and each scan of the app's one-time CSI > u push grew the mirrored stack while the renderer's post-replay reset drained xterm's copy. The TUI's single exit pop (omp emits a bare CSI < u on quit) then landed on a stale frame, leaving Option+B/F/D kitty-encoded in a plain shell. Replay paths now scan with scanReplay(), which applies pushes as idempotent sets so redelivery cannot grow the stack; the live-output funnel and the daemon's once-per-byte emulator keep full stack semantics. - Layout-correct base keys: kitty CSI-u reports must carry the key's unshifted codepoint in the active layout, but the encoder resolved it from a US-QWERTY physical-code table — on Dvorak/Colemak/AZERTY-class layouts (exactly the population whose effective Option-as-Alt default activates this path) the wrong key's chord fired, e.g. Colemak Option+P sent alt+r. A new keyboard-layout module caches Chromium's KeyboardLayoutMap (fetched at terminal setup and on focus-in, like the option-as-alt probe) and the policy resolves through it before the US fallback. Verified live: the layout-resolved bytes open omp's model selector; the US-table bytes do not. * fix(terminal): reset kitty mirror on fresh spawn to cover replaced-PTY late exits The exit handler resets the per-pane kitty keyboard mirror, but a late exit from a replaced PTY takes the stale-transport early return and skips it — so a restart-in-place could leak the old TUI's kitty flags into the fresh shell, kitty-encoding Option chords the shell cannot parse. A fresh spawn is by definition a new process with kitty state at zero, so startFreshSpawn now resets the reused tracker itself, alongside the other per-pane mode state it already clears. Reattach paths are untouched, so a live TUI's mirrored flags still survive reconnects. --------- Co-authored-by: Brennan Benson --- src/main/daemon/headless-emulator.test.ts | 40 ++++ src/main/daemon/headless-emulator.ts | 16 +- src/main/daemon/types.ts | 4 + .../components/terminal-pane/TerminalPane.tsx | 8 + .../terminal-pane/keyboard-handlers.ts | 29 ++- .../terminal-pane/pty-connection-types.ts | 5 + .../terminal-pane/pty-connection.test.ts | 27 +++ .../terminal-pane/pty-connection.ts | 42 ++++ .../terminal-shortcut-policy.test.ts | 171 ++++++++++++++++ .../terminal-pane/terminal-shortcut-policy.ts | 122 ++++++++---- .../use-terminal-pane-lifecycle.ts | 5 + .../layout-base-character.test.ts | 45 +++++ .../keyboard-layout/layout-base-character.ts | 78 ++++++++ ...rminal-kitty-keyboard-mode-tracker.test.ts | 147 ++++++++++++++ .../terminal-kitty-keyboard-mode-tracker.ts | 186 ++++++++++++++++++ 15 files changed, 889 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts create mode 100644 src/renderer/src/lib/keyboard-layout/layout-base-character.ts create mode 100644 src/shared/terminal-kitty-keyboard-mode-tracker.test.ts create mode 100644 src/shared/terminal-kitty-keyboard-mode-tracker.ts diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 1a7dd6c5abe..0a5832247c6 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -498,6 +498,46 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') }) + + it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // OMP/pi negotiate progressive enhancement with a level-1 push. + await emulator.write('\x1b[>1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(1) + expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u') + }) + + it('omits kitty rehydration after the TUI pops its flags', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[>1u') + await emulator.write('\x1b[ { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[>1u') + + const snapshot = emulator.getSnapshot() + const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h') + const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u') + expect(altScreenIndex).toBeGreaterThanOrEqual(0) + expect(kittyIndex).toBeGreaterThan(altScreenIndex) + }) + + it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[>1u') + await emulator.write('\x1b[!p') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(0) + }) }) describe('dispose', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 74381d3d543..7ce69db0cc8 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -4,6 +4,7 @@ import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -36,6 +37,7 @@ export class HeadlessEmulator { private lastTitle: string | null = null private oscScanTail = '' private privateModes = new TerminalPrivateModeTracker() + private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() private restoredOscLinks: TerminalOscLinkRange[] = [] // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's // parser, not the screen buffer, so serialize() drops them and the next @@ -97,6 +99,7 @@ export class HeadlessEmulator { // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. this.privateModes.scan(data) + this.kittyKeyboardModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -124,6 +127,7 @@ export class HeadlessEmulator { // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) this.privateModes.scan(data) + this.kittyKeyboardModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } @@ -286,7 +290,8 @@ export class HeadlessEmulator { sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, - alternateScreen: buffer.type === 'alternate' + alternateScreen: buffer.type === 'alternate', + kittyKeyboardFlags: this.kittyKeyboardModes.flags } } @@ -326,6 +331,15 @@ export class HeadlessEmulator { } else if (modes.sgrMouseMode) { seqs.push('\x1b[?1006h') } + // Why: kitty keyboard flags are per-screen state SerializeAddon cannot + // capture; without re-arming them, the still-running TUI keeps expecting + // protocol-encoded keys the restored client no longer sends. `=` (set) + // instead of `>` (push) so repeated replays cannot grow the flag stack. + // Emitted after the alt-screen switch above so the flags land on the + // screen the TUI negotiated them on. + if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) { + seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`) + } return seqs.join('') } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 10d27f2c8e0..6c1c7c281a0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -47,6 +47,10 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean + /** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated; + * 0/absent when inactive. SerializeAddon cannot capture these, so the + * emulator mirrors them for snapshot rehydration. */ + kittyKeyboardFlags?: number } // The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 003188d6c16..297b28da36d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -43,6 +43,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { applyExpandedLayoutTo, cancelPendingPaneSizeRefreshFrames, @@ -296,6 +297,10 @@ export default function TerminalPane({ // read this map at dispatch time to pass cwd into splitPane. const paneCwdRef = useRef>(new Map()) const paneMode2031Ref = useRef>(new Map()) + // Why: per-pane mirror of the kitty keyboard flags negotiated by the pane's + // application (fed from PTY output in pty-connection). The keyboard policy + // reads it to encode Option chords as kitty CSI-u for opted-in TUIs. + const paneKittyKeyboardModesRef = useRef>(new Map()) const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(new Map()) // Why: tracks panes currently replaying recorded PTY bytes into xterm @@ -1472,6 +1477,7 @@ export default function TerminalPane({ paneTransportsRef, paneCwdRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, panePtyBindingsRef, replayingPanesRef, @@ -1696,6 +1702,7 @@ export default function TerminalPane({ startup: { command: 'codex' }, paneTransportsRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, replayingPanesRef, isActiveRef, @@ -1795,6 +1802,7 @@ export default function TerminalPane({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first' }) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index ed78a5d6500..b5bf46b7d0c 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -14,7 +14,12 @@ import { type TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { PaneCwdMap } from './resolve-split-cwd' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' +import { + getLayoutBaseCharacterForCode, + prefetchLayoutBaseCharacters +} from '@/lib/keyboard-layout/layout-base-character' import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection' import { isFindQueryTooLarge } from '@/lib/find-query-bounds' import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions' @@ -161,6 +166,7 @@ type KeyboardHandlersDeps = { searchOpenRef: React.RefObject searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject + paneKittyKeyboardModesRef?: React.RefObject> keybindings?: KeybindingOverrides terminalShortcutPolicy?: TerminalShortcutPolicy } @@ -194,6 +200,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy = 'orca-first' }: KeyboardHandlersDeps): void { @@ -206,6 +213,12 @@ export function useTerminalKeyboardShortcuts({ const isWindows = navigator.userAgent.includes('Windows') const shortcutPlatform: KeybindingPlatform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' + // Why: kitty Option-chord encoding resolves base keys through the async + // KeyboardLayoutMap; prefetch so the map is cached before the first chord. + if (isMac) { + prefetchLayoutBaseCharacters() + } + // Why: KeyboardEvent.location on a character key (e.g. Period) always // reports that key's own position (0 = standard), not which modifier is // held. To distinguish left vs right Option, we record the Option key's @@ -293,6 +306,17 @@ export function useTerminalKeyboardShortcuts({ }) } + // Why: the pane's TUI opted into kitty keyboard reporting via CSI > u; + // the tracker mirrors that from PTY output so the policy can encode + // Option chords the way the application negotiated. + const isKittyKeyboardActivePane = (): boolean => { + const activePane = manager.getActivePane() ?? manager.getPanes()[0] + if (!activePane) { + return false + } + return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 + } + const action = resolveTerminalShortcutAction( e, isMac, @@ -300,7 +324,9 @@ export function useTerminalKeyboardShortcuts({ optionKeyLocation, isWindows, keybindings, - isLocalWindowsConptyPane + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, + getLayoutBaseCharacterForCode ) if (!action) { return @@ -537,6 +563,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy, tabId, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 01c98e84557..70abd4fae3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -7,6 +7,7 @@ import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color- import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' export type PtyConnectionDeps = { tabId: string @@ -37,6 +38,10 @@ export type PtyConnectionDeps = { restoredPtyIdByLeafId?: Record paneTransportsRef: React.RefObject> paneMode2031Ref: React.RefObject> + /** Per-pane mirror of the kitty keyboard flags the pane's application + * negotiated. Fed from PTY output here; read by the keyboard policy. */ + paneKittyKeyboardModesRef: React.RefObject> + paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef 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 662e46aae52..72aac62a8e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -486,6 +486,7 @@ function createDeps(overrides: Record = {}) { restoredPtyIdByLeafId: {}, paneTransportsRef: { current: new Map() }, paneMode2031Ref: { current: new Map() }, + paneKittyKeyboardModesRef: { current: new Map() }, paneLastThemeModeRef: { current: new Map() }, replayingPanesRef: { current: new Map() }, isActiveRef: { current: true }, @@ -921,6 +922,32 @@ describe('connectPanePty', () => { ) }) + // Why: a late exit from a replaced PTY takes the stale-transport early + // return in onExit and skips the kitty mirror reset there — a fresh spawn + // must therefore reset the reused per-pane tracker itself, or a + // restart-in-place leaks the old TUI's kitty flags into a fresh shell. + it('resets a stale kitty keyboard mirror when spawning a fresh PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const { TerminalKittyKeyboardModeTracker } = + await import('../../../../shared/terminal-kitty-keyboard-mode-tracker') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const staleTracker = new TerminalKittyKeyboardModeTracker() + staleTracker.scan('\x1b[>1u') + expect(staleTracker.flags).toBe(1) + // Why: a unique tab id keeps this pane's key clear of pendingSpawnByPaneKey + // entries from other tests, so the connect deterministically fresh-spawns. + const deps = createDeps({ + tabId: 'tab-kitty-fresh-spawn', + paneKittyKeyboardModesRef: { current: new Map([[91, staleTracker]]) } + }) + + connectPanePty(createPane(91) as never, createManager(91) as never, deps as never) + await flushAsyncTicks() + + expect(staleTracker.flags).toBe(0) + }) + it('threads the resolved local project runtime into IPC terminal transport options', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e86afd8018e..94fd3774fa4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -9,6 +9,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' @@ -1125,6 +1126,19 @@ export function connectPanePty( // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. // Use the stable layout leaf UUID, not the renderer-local numeric pane id. const cacheKey = makePaneKey(deps.tabId, pane.leafId) + // Why: mirrors the kitty keyboard flags the pane's application negotiates. + // Fed only from application output (live PTY bytes + daemon replay + // payloads), never from renderer-generated resets, so it reflects what the + // application expects even after defensive renderer-side kitty wipes. + const kittyKeyboardModes = (() => { + const existing = deps.paneKittyKeyboardModesRef.current.get(pane.id) + if (existing) { + return existing + } + const created = new TerminalKittyKeyboardModeTracker() + deps.paneKittyKeyboardModesRef.current.set(pane.id, created) + return created + })() const getSleepingRecordForPane = ( state: ReturnType ): { paneKey: string; record: SleepingAgentSessionRecord } | null => { @@ -2178,6 +2192,9 @@ export function connectPanePty( handledExitPtyId = ptyId agentCompletionCoordinator.dispose() clearPanePtyFitBinding() + // Why: the negotiating application died with its PTY; any replacement + // session starts with kitty keyboard flags at zero. + kittyKeyboardModes.reset() const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) if (!isSuppressedExit) { deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId) @@ -3819,6 +3836,11 @@ export function connectPanePty( ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a fresh spawn is a new process with kitty keyboard flags at + // zero. The exit-handler reset alone is not enough: a late exit from a + // replaced PTY takes the stale-transport early return and skips it, so + // a restart-in-place would leak the old TUI's flags into a fresh shell. + kittyKeyboardModes.reset() prepareFreshShellViewportForSpawn(options) if (connectionId && startupOverride?.command) { // Why: SSH providers use `command` only as spawn metadata; the renderer @@ -4183,6 +4205,11 @@ export function connectPanePty( // must clear a stale agent signal from an earlier payload. rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) } + // Why: replayed application bytes carry the live TUI's kitty keyboard + // negotiation; the mirror must re-arm from them after a reload. Replay + // semantics: relay reconnects redeliver the same window, so pushes + // apply as sets to keep the mirrored stack from accumulating frames. + kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) @@ -4466,6 +4493,10 @@ export function connectPanePty( foreground: boolean, opts?: { hiddenStartupRendererQuery?: boolean } ): void { + // Why: every application byte funnels through here (foreground, hidden, + // and background writes), so this is the one place the kitty keyboard + // mirror observes the pane's protocol negotiation. + kittyKeyboardModes.scan(data) if (foreground) { resetHiddenOutputRestoreIfPtyChanged() resetHiddenRendererRiskState() @@ -5571,6 +5602,10 @@ export function connectPanePty( } } writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode // reset. We only drop renderer-owned state that should not leak from @@ -5597,6 +5632,10 @@ export function connectPanePty( // 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') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) writeReplayData(connectResult.replay) writeReplayData(reattachReplayResetSequence(connectResult.replay)) sendFocusedReattachFocusInAfterReplay() @@ -5624,6 +5663,9 @@ export function connectPanePty( // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so // reset them to match the fresh shell's expectations. writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() consumeRestoredViewportBlankingMarker() writeFreshShellViewportBlanking() if (!isRemoteRuntimePtyId(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 0f0c4cec3d1..8bf8159d9d1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -510,3 +510,174 @@ describe('resolveTerminalShortcutAction', () => { ).toEqual({ type: 'splitActivePane', direction: 'horizontal' }) }) }) + +describe('kitty keyboard protocol panes', () => { + const kittyActive = (): boolean => true + const kittyInactive = (): boolean => false + + const resolveKitty = ( + input: TerminalShortcutEvent, + macOptionAsAlt: 'true' | 'false' | 'left' | 'right' = 'false', + optionKeyLocation = 0, + active: () => boolean = kittyActive + ) => + resolveTerminalShortcutAction( + input, + true, + macOptionAsAlt, + optionKeyLocation, + false, + undefined, + undefined, + active + ) + + it('encodes Option+letter as kitty CSI-u with the physical base key in compose mode', () => { + // macOS composition reports key='π' for Option+P on ABC/compose layouts; + // OMP binds alt+p (temporary model) and alt+m (model selector). + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + expect(resolveKitty(event({ key: 'µ', code: 'KeyM', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[109;3u' + }) + }) + + it('includes shift in the kitty modifier field', () => { + expect(resolveKitty(event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[112;4u' + }) + }) + + it('encodes Option+digit and mapped Option+punctuation', () => { + expect(resolveKitty(event({ key: '¡', code: 'Digit1', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[49;3u' + }) + expect(resolveKitty(event({ key: '≥', code: 'Period', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[46;3u' + }) + }) + + it('exempts dead keys so Option composition still starts', () => { + expect(resolveKitty(event({ key: 'Dead', code: 'KeyE', altKey: true }))).toBeNull() + }) + + it('defers to xterm in macOptionAsAlt=true mode (native kitty encoding is correct there)', () => { + expect(resolveKitty(event({ key: 'p', code: 'KeyP', altKey: true }), 'true')).toBeNull() + }) + + it('keeps shift+Option composition untouched in non-kitty panes', () => { + expect( + resolveKitty( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'false', + 0, + kittyInactive + ) + ).toBeNull() + // Meta-side Option in 'left' mode stays shift-exempt without kitty. + expect( + resolveKitty( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'left', + 1, + kittyInactive + ) + ).toBeNull() + }) + + it('keeps compose-mode behavior unchanged when the pane is not kitty-active', () => { + expect( + resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }), 'false', 0, kittyInactive) + ).toBeNull() + // The B/F/D readline patches still apply without kitty. + expect( + resolveKitty(event({ key: '∫', code: 'KeyB', altKey: true }), 'false', 0, kittyInactive) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + }) + + it('encodes the compose-side Option key as kitty CSI-u in left/right modes', () => { + // In 'left' mode the right Option normally composes; a kitty pane asked + // for modifier-accurate keys, so it gets alt-encoded too. + expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 2)).toEqual({ + type: 'sendInput', + data: '\x1b[108;3u' + }) + // The designated meta side upgrades from legacy Esc+letter to CSI-u. + expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 1)).toEqual({ + type: 'sendInput', + data: '\x1b[108;3u' + }) + }) + + it('yields Alt+Arrow and Alt+Backspace to xterm kitty encoding', () => { + expect(resolveKitty(event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }))).toBeNull() + expect(resolveKitty(event({ key: 'Backspace', code: 'Backspace', altKey: true }))).toBeNull() + // Without kitty, the readline translations still apply. + expect( + resolveKitty( + event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }), + 'false', + 0, + kittyInactive + ) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + expect( + resolveKitty( + event({ key: 'Backspace', code: 'Backspace', altKey: true }), + 'false', + 0, + kittyInactive + ) + ).toEqual({ type: 'sendInput', data: '\x1b\x7f' }) + }) + + it('does not intercept Option chords with Cmd or Ctrl held', () => { + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, metaKey: true }))).toBeNull() + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, ctrlKey: true }))).toBeNull() + }) + + it('resolves the kitty base key through the active layout map when provided', () => { + const resolveWithLayout = ( + input: TerminalShortcutEvent, + layoutBaseCharacterForCode: (code: string) => string | undefined + ) => + resolveTerminalShortcutAction( + input, + true, + 'false', + 0, + false, + undefined, + undefined, + kittyActive, + layoutBaseCharacterForCode + ) + + // AZERTY types M at the physical Semicolon position; the layout map must + // win over the US punctuation table so the chord reports alt+m, not alt+;. + const azerty = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined) + expect(resolveWithLayout(event({ key: 'µ', code: 'Semicolon', altKey: true }), azerty)).toEqual( + { type: 'sendInput', data: '\x1b[109;3u' } + ) + + // Colemak types P at the physical KeyR position. + const colemak = (code: string): string | undefined => (code === 'KeyR' ? 'p' : undefined) + expect(resolveWithLayout(event({ key: 'π', code: 'KeyR', altKey: true }), colemak)).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + + // Falls back to the US table when the layout map has no entry. + const empty = (): string | undefined => undefined + expect(resolveWithLayout(event({ key: 'π', code: 'KeyP', altKey: true }), empty)).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 35cc6c00ffe..69bc914d048 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -42,6 +42,26 @@ export type TerminalShortcutAction = | { type: 'scrollViewport'; position: 'top' | 'bottom' } | { type: 'sendInput'; data: string } +/** Kitty keyboard protocol modifier field: 1 + shift(1) + alt(2). */ +function kittyAltModifiers(shiftKey: boolean): number { + return shiftKey ? 4 : 3 +} + +/** The un-shifted ASCII character for a physical key code (letters, digits, + * and the punctuation map above), or undefined for unmapped codes. */ +function resolveUnshiftedCharacterForCode(code: string | undefined): string | undefined { + if (!code) { + return undefined + } + if (code.startsWith('Key') && code.length === 4) { + return code.charAt(3).toLowerCase() + } + if (code.startsWith('Digit') && code.length === 6) { + return code.charAt(5) + } + return PUNCTUATION_CODE_MAP[code] +} + /** * Resolves terminal keyboard events before xterm receives them. * Keeps configurable Orca shortcuts and terminal byte fallbacks in one @@ -58,7 +78,16 @@ export function resolveTerminalShortcutAction( // ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow // word-nav rule below, so the execution-host lookup it performs stays off the // hot path for every other keystroke. - isLocalWindowsConptyPane?: () => boolean + isLocalWindowsConptyPane?: () => boolean, + // Why: lazily reports whether the active pane's application has enabled the + // kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation + // below on the application's own opt-in, so shells keep composition. + isKittyKeyboardActivePane?: () => boolean, + // Why: kitty key reports carry the key's unshifted codepoint in the active + // layout; the physical-code table above is US QWERTY and reports the wrong + // key on Dvorak/Colemak/AZERTY-class layouts. This resolves through + // Chromium's KeyboardLayoutMap when it is available. + layoutBaseCharacterForCode?: (code: string) => string | undefined ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -184,6 +213,11 @@ export function resolveTerminalShortcutAction( !event.shiftKey && event.key === 'Backspace' ) { + // Why: a kitty-protocol TUI binds the CSI 127;3u that xterm's kitty + // encoder emits natively; the legacy \x1b\x7f fallback would bypass it. + if (isKittyKeyboardActivePane?.()) { + return null + } return { type: 'sendInput', data: '\x1b\x7f' } } @@ -194,6 +228,11 @@ export function resolveTerminalShortcutAction( !event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') ) { + // Why: a kitty-protocol TUI binds alt+arrow via the CSI 1;3D / 1;3C that + // xterm's kitty encoder emits natively; \eb/\ef would reach it as alt+b/f. + if (isKittyKeyboardActivePane?.()) { + return null + } // Why: xterm.js would otherwise emit \e[1;3D / \e[1;3C for option/alt+arrow, // which default readline (bash, zsh) does not bind to backward-word / // forward-word — so word navigation silently doesn't work without a custom @@ -240,46 +279,61 @@ export function resolveTerminalShortcutAction( // // The handling depends on the macOptionAsAlt setting (mirrors Ghostty): // - 'true': xterm handles all Option as Meta natively; nothing to do here. + // - kitty-protocol pane (any other mode): the TUI asked for modifier-accurate + // keys, so every Option chord is encoded as kitty CSI-u with the physical + // base key (Option+P → \x1b[112;3u). Without this, xterm's kitty encoder + // reports the composed codepoint (alt+π), which no TUI binds — the chord + // neither triggers the hotkey nor types the character (issue: OMP Alt+P / + // Alt+M dead on compose layouts). Dead keys are exempt so composition + // (Option+E → ´) keeps working. // - 'false': compensate the three most critical readline shortcuts (B/F/D). // - 'left'/'right': the designated Option key acts as full Meta (emit Esc+ // for any single letter); the other key composes, with B/F/D compensated. - if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && !event.shiftKey) { - // Why: event.location on a character key reports that key's position (always - // 0 for standard keys), NOT which modifier is held. The caller must track - // the Option key's own keydown location and pass it as optionKeyLocation. - const isLeftOption = optionKeyLocation === 1 - const isRightOption = optionKeyLocation === 2 - - const shouldActAsMeta = - (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) - - if (shouldActAsMeta) { - // Emit Esc+key for letter keys (e.g. Option+B → \x1bb) - if (event.code?.startsWith('Key') && event.code.length === 4) { - const letter = event.code.charAt(3).toLowerCase() - return { type: 'sendInput', data: `\x1b${letter}` } - } - // Emit Esc+digit for number keys (e.g. Option+1 → \x1b1) - if (event.code?.startsWith('Digit') && event.code.length === 6) { - return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` } - } - const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined - if (punct) { - return { type: 'sendInput', data: `\x1b${punct}` } + if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && macOptionAsAlt !== 'true') { + if (event.key !== 'Dead' && isKittyKeyboardActivePane?.()) { + const baseCharacter = + (event.code ? layoutBaseCharacterForCode?.(event.code) : undefined) ?? + resolveUnshiftedCharacterForCode(event.code) + if (baseCharacter) { + return { + type: 'sendInput', + data: `\x1b[${baseCharacter.codePointAt(0)};${kittyAltModifiers(event.shiftKey)}u` + } } } - // In 'false', 'left', or 'right' mode, the compose-side Option key still - // needs the three most critical readline shortcuts patched. - if (macOptionAsAlt !== 'true' && !shouldActAsMeta) { - if (event.code === 'KeyB') { - return { type: 'sendInput', data: '\x1bb' } + if (!event.shiftKey) { + // Why: event.location on a character key reports that key's position + // (always 0 for standard keys), NOT which modifier is held. The caller + // must track the Option key's own keydown location and pass it as + // optionKeyLocation. + const isLeftOption = optionKeyLocation === 1 + const isRightOption = optionKeyLocation === 2 + + const shouldActAsMeta = + (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) + + if (shouldActAsMeta) { + // Emit Esc+key (e.g. Option+B → \x1bb) for letters, digits, and + // mapped punctuation. + const character = resolveUnshiftedCharacterForCode(event.code) + if (character) { + return { type: 'sendInput', data: `\x1b${character}` } + } } - if (event.code === 'KeyF') { - return { type: 'sendInput', data: '\x1bf' } - } - if (event.code === 'KeyD') { - return { type: 'sendInput', data: '\x1bd' } + + // In 'false', 'left', or 'right' mode, the compose-side Option key still + // needs the three most critical readline shortcuts patched. + if (!shouldActAsMeta) { + if (event.code === 'KeyB') { + return { type: 'sendInput', data: '\x1bb' } + } + if (event.code === 'KeyF') { + return { type: 'sendInput', data: '\x1bf' } + } + if (event.code === 'KeyD') { + return { type: 'sendInput', data: '\x1bd' } + } } } } 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 1f590985dce..7999db503ca 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,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { PaneManager, type PaneExternalDropHandler, @@ -242,6 +243,7 @@ type UseTerminalPaneLifecycleDeps = { * context-menu split handlers can read it synchronously for cache hits. */ paneCwdRef: React.RefObject paneMode2031Ref: React.RefObject> + paneKittyKeyboardModesRef: React.RefObject> paneLastThemeModeRef: React.RefObject> panePtyBindingsRef: React.RefObject> replayingPanesRef: ReplayingPanesRef @@ -516,6 +518,7 @@ export function useTerminalPaneLifecycle({ paneTransportsRef, paneCwdRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, panePtyBindingsRef, replayingPanesRef, @@ -736,6 +739,7 @@ export function useTerminalPaneLifecycle({ startup: startupWithSetupSplitWait, paneTransportsRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, replayingPanesRef, restoredViewportBlankingPanesRef, @@ -1201,6 +1205,7 @@ export function useTerminalPaneLifecycle({ mode2031DisposablesRef.current.delete(paneId) } paneMode2031Ref.current.delete(paneId) + paneKittyKeyboardModesRef.current.delete(paneId) paneLastThemeModeRef.current.delete(paneId) const osc52Disposable = osc52DisposablesRef.current.get(paneId) if (osc52Disposable) { diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts new file mode 100644 index 00000000000..f4cf3fb943e --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _setLayoutMapForTests, + getLayoutBaseCharacterForCode, + normalizeLayoutBaseCharacter +} from './layout-base-character' + +describe('normalizeLayoutBaseCharacter', () => { + it('accepts a single printable codepoint, lowercased', () => { + expect(normalizeLayoutBaseCharacter('p')).toBe('p') + expect(normalizeLayoutBaseCharacter('P')).toBe('p') + expect(normalizeLayoutBaseCharacter('ö')).toBe('ö') + expect(normalizeLayoutBaseCharacter(';')).toBe(';') + }) + + it('rejects empty, named-key, multi-codepoint, and control values', () => { + expect(normalizeLayoutBaseCharacter(undefined)).toBeUndefined() + expect(normalizeLayoutBaseCharacter('')).toBeUndefined() + expect(normalizeLayoutBaseCharacter('Dead')).toBeUndefined() + expect(normalizeLayoutBaseCharacter('\t')).toBeUndefined() + expect(normalizeLayoutBaseCharacter(' ')).toBeUndefined() + }) +}) + +describe('getLayoutBaseCharacterForCode', () => { + afterEach(() => { + _setLayoutMapForTests(null) + }) + + it('returns undefined without a cached map, and resolves through one', () => { + expect(getLayoutBaseCharacterForCode('KeyP')).toBeUndefined() + + const azertyEntries = new Map([ + ['Semicolon', 'm'], + ['KeyE', 'Dead'] + ]) + _setLayoutMapForTests({ + get: (code) => azertyEntries.get(code), + size: azertyEntries.size + }) + expect(getLayoutBaseCharacterForCode('Semicolon')).toBe('m') + expect(getLayoutBaseCharacterForCode('KeyE')).toBeUndefined() + expect(getLayoutBaseCharacterForCode('KeyZ')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts new file mode 100644 index 00000000000..d712d897ebd --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts @@ -0,0 +1,78 @@ +/** + * Synchronous lookup of the active keyboard layout's base (unshifted) + * character for a physical `KeyboardEvent.code`. + * + * Why: kitty keyboard CSI-u reports must carry the codepoint of the key in + * the *current layout* with no modifiers. Deriving it from the physical code + * alone assumes US QWERTY and reports the wrong key on Dvorak, Colemak, + * AZERTY, QWERTZ, etc. — misfiring TUI hotkeys. Chromium's KeyboardLayoutMap + * is the layout-true source, but it resolves asynchronously, so this module + * prefetches it and refreshes on window focus-in (every macOS layout-switch + * path blurs and refocuses the window; Chromium has no layoutchange event — + * see option-as-alt-probe.ts). + */ +import type { LayoutMapLike } from './detect-option-as-alt' + +type NavigatorWithKeyboard = Navigator & { + keyboard?: { + getLayoutMap: () => Promise + } +} + +let cachedLayoutMap: LayoutMapLike | null = null +let focusListenerAttached = false + +async function refreshLayoutMap(): Promise { + const keyboard = (window.navigator as NavigatorWithKeyboard).keyboard + if (!keyboard?.getLayoutMap) { + return + } + try { + cachedLayoutMap = await keyboard.getLayoutMap() + } catch { + // Why: getLayoutMap can transiently reject; keep the last known map + // instead of dropping layout awareness mid-session. + } +} + +/** Idempotent. Kicks off the initial fetch and keeps the cache fresh across + * layout switches. Call from terminal keyboard setup so the map is resolved + * before the first Option chord. */ +export function prefetchLayoutBaseCharacters(): void { + if (focusListenerAttached || typeof window === 'undefined') { + return + } + focusListenerAttached = true + window.addEventListener('focus', () => { + void refreshLayoutMap() + }) + void refreshLayoutMap() +} + +/** A layout map entry is usable as a kitty base key only if it is a single + * printable codepoint (dead keys report names like 'Dead'; some entries are + * empty). Exposed for tests. */ +export function normalizeLayoutBaseCharacter(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + const lowered = value.toLowerCase() + const codePoints = [...lowered] + if (codePoints.length !== 1) { + return undefined + } + const codePoint = lowered.codePointAt(0) as number + return codePoint <= 0x20 ? undefined : lowered +} + +/** The active layout's unshifted character for a physical key code, or + * undefined when the map is unavailable or the key has no single printable + * base character (callers fall back to the US table). */ +export function getLayoutBaseCharacterForCode(code: string): string | undefined { + return normalizeLayoutBaseCharacter(cachedLayoutMap?.get(code)) +} + +/** Test-only: replace or clear the cached layout map. */ +export function _setLayoutMapForTests(map: LayoutMapLike | null): void { + cachedLayoutMap = map +} diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts new file mode 100644 index 00000000000..6d88f56a070 --- /dev/null +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' + +describe('TerminalKittyKeyboardModeTracker', () => { + it('starts inactive and ignores non-kitty sequences', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + expect(tracker.flags).toBe(0) + tracker.scan('plain output \x1b[?2004h\x1b[38;5;10mcolored\x1b[0m') + expect(tracker.flags).toBe(0) + }) + + it('does not treat CSI u (restore cursor) or the CSI ? u query as kitty state', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[u\x1b[?u') + expect(tracker.flags).toBe(0) + }) + + it('tracks push and pop like xterm, including the pop-to-empty zeroing', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[>7u') + expect(tracker.flags).toBe(7) + tracker.scan('\x1b[5u\x1b[ { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[=1;1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[=2;2u') + expect(tracker.flags).toBe(3) + tracker.scan('\x1b[=1;3u') + expect(tracker.flags).toBe(2) + // Mode defaults to 1 (set) when omitted. + tracker.scan('\x1b[=4u') + expect(tracker.flags).toBe(4) + }) + + it("clears state for Orca's defensive reset sequence and RIS", () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + tracker.scan('\x1b[<99u\x1b[=0u') + expect(tracker.flags).toBe(0) + + tracker.scan('\x1b[>1u') + tracker.scan('\x1bc') + expect(tracker.flags).toBe(0) + }) + + it('keeps per-screen flags across alternate-screen switches', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[?1049h') + expect(tracker.flags).toBe(0) + tracker.scan('\x1b[>2u') + expect(tracker.flags).toBe(2) + tracker.scan('\x1b[?1049l') + expect(tracker.flags).toBe(1) + }) + + it('handles sequences split across chunks and C1 CSI', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>') + expect(tracker.flags).toBe(0) + tracker.scan('1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x9b<99u') + expect(tracker.flags).toBe(0) + tracker.scan('\x9b>7u') + expect(tracker.flags).toBe(7) + }) + + it('caps the mirrored stack without losing the current flags', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + for (let i = 0; i < 40; i++) { + tracker.scan(`\x1b[>${(i % 3) + 1}u`) + } + expect(tracker.flags).toBe((39 % 3) + 1) + }) + + it('reset() returns to the inactive state', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[?1049h\x1b[>2u') + tracker.reset() + expect(tracker.flags).toBe(0) + tracker.scan('\x1b[?1049l') + expect(tracker.flags).toBe(0) + }) + + it('clears kitty state on DECSTR (CSI ! p) like xterm, without switching screens', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[!p') + expect(tracker.flags).toBe(0) + + // xterm's soft reset wipes both screens' slots but stays on the current + // buffer; a later alt-screen exit must not resurrect pre-reset flags. + const onAlt = new TerminalKittyKeyboardModeTracker() + onAlt.scan('\x1b[>1u\x1b[?1049h\x1b[>2u') + expect(onAlt.flags).toBe(2) + onAlt.scan('\x1b[!p') + expect(onAlt.flags).toBe(0) + onAlt.scan('\x1b[?1049l') + expect(onAlt.flags).toBe(0) + }) + + it('handles DECSTR split across chunks', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[!') + expect(tracker.flags).toBe(1) + tracker.scan('p') + expect(tracker.flags).toBe(0) + }) + + it('applies replayed pushes as sets so redelivered windows cannot grow the stack', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + // Live negotiation, then two relay reconnects redelivering the same + // retained window containing the app's one-time push. + tracker.scan('\x1b[>1u') + tracker.scanReplay('\x1b[>1u') + tracker.scanReplay('\x1b[>1u') + expect(tracker.flags).toBe(1) + // The TUI's single exit pop must drain to zero despite the redeliveries. + tracker.scan('\x1b[ { + const fresh = new TerminalKittyKeyboardModeTracker() + fresh.scanReplay('\x1b[>1u') + expect(fresh.flags).toBe(1) + fresh.scan('\x1b[1uoutput\x1b[ u push, CSI < u pop, + * CSI = u set) by scanning the raw PTY output stream, replicating xterm's + * exact stack/screen algorithm including the per-screen flag slots swapped by + * DECSET/DECRST 47/1047/1049, the full reset on RIS, and the soft reset on + * DECSTR (CSI ! p). + * + * Why a mirror instead of reading xterm's internal state: Orca defensively + * wipes the renderer terminal's kitty flags at moments when the TUI may have + * died (Ctrl+C interrupts, reattach resets) while the TUI is usually still + * alive and expecting protocol-encoded input. This tracker is fed only by + * application output, so it reflects what the *application* negotiated, + * independent of renderer-side defensive writes. The daemon reuses it to + * carry flags into snapshots (xterm's SerializeAddon does not serialize kitty + * state). + */ +export class TerminalKittyKeyboardModeTracker { + private scanTail = '' + private currentFlags = 0 + private mainFlags = 0 + private altFlags = 0 + private mainStack: number[] = [] + private altStack: number[] = [] + private alternateScreenActive = false + + /** Current effective kitty keyboard flags (0 = protocol inactive). */ + get flags(): number { + return this.currentFlags + } + + reset(): void { + this.scanTail = '' + this.currentFlags = 0 + this.mainFlags = 0 + this.altFlags = 0 + this.mainStack = [] + this.altStack = [] + this.alternateScreenActive = false + } + + scan(data: string): void { + this.scanInternal(data, false) + } + + /** + * Scan bytes replayed from a retained history window (reattach payloads, + * relay replays, daemon snapshots). Replays can redeliver the application's + * one-time CSI > u push — applying it with stack semantics on every delivery + * grows the mirrored stack, so the TUI's eventual single pop lands on a + * stale frame and Option chords stay kitty-encoded in a plain shell. Pushes + * seen during replay therefore apply as idempotent sets. Known limit: a + * NESTED push/push/pop inside the window collapses to flags 0 on the pop — + * unavoidable stackless-replay tradeoff (a redelivered push is byte-wise + * indistinguishable from a new one); real TUIs push once at startup. + */ + scanReplay(data: string): void { + this.scanInternal(data, true) + } + + private scanInternal(data: string, replay: boolean): 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 kittyModeRe = /\x1bc|(?:\x1b\[|\x9b)(?:!p|\?([0-9;]+)([hl])|([<>=])([0-9;]*)u)/g + let match: RegExpExecArray | null + while ((match = kittyModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + // RIS resets kitty state and returns to the main screen. + const tail = this.scanTail + this.reset() + this.scanTail = tail + continue + } + if (match[0].endsWith('!p')) { + this.applySoftReset() + continue + } + if (match[1] !== undefined) { + this.applyScreenSwitch(match[1], match[2] === 'h') + continue + } + this.applyKittySequence(match[3], match[4] ?? '', replay) + } + } + + private applySoftReset(): void { + // Why: xterm's DECSTR (CSI ! p) wipes kitty flags and stacks for both + // screens via coreService.reset but does not switch buffers — mirror that + // so a soft-resetting TUI stops receiving kitty-encoded Option chords. + this.currentFlags = 0 + this.mainFlags = 0 + this.altFlags = 0 + this.mainStack = [] + this.altStack = [] + } + + private applyScreenSwitch(params: string, enabled: boolean): void { + for (const rawParam of params.split(';')) { + const param = Number(rawParam) + if (param !== 47 && param !== 1047 && param !== 1049) { + continue + } + // Why: xterm swaps the current flags with the inactive screen's slot on + // every 47/1047/1049 transition, without an already-active guard — + // mirror it exactly so this state matches what the renderer encodes. + if (enabled) { + this.mainFlags = this.currentFlags + this.currentFlags = this.altFlags + this.alternateScreenActive = true + } else { + this.altFlags = this.currentFlags + this.currentFlags = this.mainFlags + this.alternateScreenActive = false + } + } + } + + private applyKittySequence(prefix: string, params: string, replay: boolean): void { + const parsed = params.split(';').map((entry) => Number(entry)) + const stack = this.alternateScreenActive ? this.altStack : this.mainStack + if (prefix === '>') { + if (!replay) { + if (stack.length >= KITTY_STACK_LIMIT) { + stack.shift() + } + stack.push(this.currentFlags) + } + this.currentFlags = parsed[0] || 0 + return + } + if (prefix === '<') { + const count = Math.max(1, parsed[0] || 1) + for (let i = 0; i < count && stack.length > 0; i++) { + this.currentFlags = stack.pop() as number + } + if (stack.length === 0) { + this.currentFlags = 0 + } + return + } + const flags = parsed[0] || 0 + const mode = parsed.length > 1 && parsed[1] ? parsed[1] : 1 + if (mode === 1) { + this.currentFlags = flags + } else if (mode === 2) { + this.currentFlags |= flags + } else if (mode === 3) { + this.currentFlags &= ~flags + } + } + + 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 > KITTY_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + const body = tail.startsWith('\x1b[') + ? tail.slice(2) + : tail.startsWith('\x9b') + ? tail.slice(1) + : null + if (body === null) { + return '' + } + return this.isIncompleteSequenceBody(body) ? tail : '' + } + + private isIncompleteSequenceBody(body: string): boolean { + return body === '!' || /^[<>=?]?[0-9;]*$/.test(body) + } +} From 8f6e44ed5377bbe24222eae7b08baf7576ce23c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:48:10 -0700 Subject: [PATCH 09/95] Show agent session history on mobile (#6786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show agent session history on mobile Bring the desktop "Agent Session History" panel to Orca Mobile as a per-worktree screen: browse past agent transcript sessions across the host with scope tabs (Workspace/Project/All), search, grouping, session cards, and tap-to-read message previews. The transcript scan previously ran only over Electron IPC, so mobile could not reach it. Expose it over the runtime RPC protocol mobile already speaks (aiVault.listSessions) so the scan runs on whichever host owns the transcripts — correct for local and SSH/remote hosts. Both the desktop IPC handler and the new RPC method share one cache, so opening the desktop panel and the mobile screen never double-scan. The pure filter/group/display logic is lifted into /shared (the renderer re-exports it) so the standalone mobile package can reuse it. Mobile narrows scoped tabs client-side by cwd path-prefix because the host scan treats scope paths as a widening union. Resume-from-mobile is intentionally a follow-up. * Fix mobile agent history list rendering and RPC authorization - Authorize aiVault.listSessions in the mobile RPC allowlist so the mobile client's call is not rejected before dispatch (without this the screen could never load sessions at runtime). - Name each SectionList section's rows `data` (the field React Native reads) instead of `cards`, fixing a type error and silent empty-section rendering. * Address review feedback on agent session history - Match quoted repo:/path: search operator values so labels and paths with spaces match (e.g. path:"/Users/ada/My Project"). - Hold a scoped tab in loading until the worktree list resolves instead of firing an unscoped fetch that briefly shows unrelated host history; proceed once loaded even if the worktree is absent (no stuck spinner). - Clear cached host capabilities on disconnect/host-switch and failed status.get so a capability-gated action can't linger for a host that doesn't support it. - Cover the real OrcaRuntimeService codex-home forwarding path and the quoted-operator parser with tests. * Hide redundant mobile current worktree badges Co-authored-by: Orca * Resume agent sessions from mobile history (#6969) Co-authored-by: Orca * Adapt merged seams to main's lint and reply-sender hardening Co-authored-by: Orca * Cap mobile project-scope paths to the aiVault RPC bound Co-authored-by: Orca * Share the aiVault scopePaths bound between the RPC schema and mobile Co-authored-by: Orca * Guard shared AI Vault inflight cleanup against concurrent key replacement The extracted cache module's .finally() cleared inflight tracking unconditionally, dropping the if (inflightKey === key) guard its sibling outer cache kept: an older scan resolving after a different-key scan replaced the tracking would null the newer scan's dedup slot, so a re-request started a duplicate transcript rescan. Mirrors the sibling guard; the regression test flushes a macrotask so a reverted guard fails fast on the call count instead of hanging. Co-authored-by: Orca * Harden aiVault.listSessions contract and gate mobile header entry on capability - Clamp scopePaths (64) instead of rejecting, cap limit at 2000, and make executionHostId optional so mobile can omit it; restamp per caller. - Retain successful mobile terminal-create mutation ids for 60s so resume retries dedupe after transient socket drops. - Gate the session-header Agent History action on the aiVault.v1 capability (mirrors the host-list action) so old hosts never show a dead-end entry. - Fix stale contract comments (scopePaths clamp semantics; filters move includes quoted repo:/path: operator parsing). * Add subagent field to session test fixtures after #7423 merge AiVaultSession.subagent became required on main; the five fixtures added on this branch predate it. Top-level scanned sessions carry null. --------- Co-authored-by: Orca Co-authored-by: Brennan Benson --- .../h/[hostId]/agent-history/[worktreeId].tsx | 18 + mobile/app/h/[hostId]/index.tsx | 35 +- .../app/h/[hostId]/session/[worktreeId].tsx | 92 ++-- mobile/app/h/_layout.tsx | 4 + .../MobileAgentSessionHistoryIcon.tsx | 48 ++ .../MobileAgentSessionHistoryList.tsx | 177 ++++++++ .../MobileAgentSessionHistoryPanel.tsx | 421 ++++++++++++++++++ .../agent-history/agent-history-capability.ts | 4 + .../agent-history-current-worktree-badge.ts | 7 + .../agent-history-resume-target.test.ts | 372 ++++++++++++++++ .../agent-history-resume-target.ts | 304 +++++++++++++ .../agent-history-scope-paths.test.ts | 60 +++ .../agent-history-scope-paths.ts | 60 +++ .../agent-history/agent-history-sections.ts | 59 +++ .../agent-history-session-card.test.ts | 221 +++++++++ .../agent-history-session-card.ts | 68 +++ .../agent-history-session-worktree.test.ts | 86 ++++ .../agent-history-session-worktree.ts | 103 +++++ .../src/agent-history/agent-history-styles.ts | 242 ++++++++++ .../use-mobile-agent-history-state.ts | 192 ++++++++ .../worktree-navigation-actions.ts | 50 +++ .../session/MobileSessionHeaderIconButton.tsx | 39 ++ .../MobileSessionHeaderMoreActionsSheet.tsx | 57 +++ .../session/ai-vault-resume-launch.test.ts | 300 +++++++++++++ mobile/src/session/ai-vault-resume-launch.ts | 269 +++++++++++ mobile/src/session/session-panel-host.test.ts | 43 ++ mobile/src/session/session-panel-host.ts | 11 + .../worktree/mobile-workspace-lineage.test.ts | 36 +- .../src/worktree/mobile-workspace-lineage.ts | 25 +- .../src/worktree/workspace-list-ordering.ts | 124 ++++++ .../worktree/workspace-list-sections.test.ts | 313 ++++++++++++- .../src/worktree/workspace-list-sections.ts | 107 +---- mobile/src/worktree/workspace-list-types.ts | 8 + .../worktree/worktree-list-snapshot.test.ts | 44 ++ mobile/src/worktree/worktree-list-snapshot.ts | 7 + mobile/tsconfig.json | 2 +- src/main/ai-vault/cached-session-list.ts | 101 +++++ src/main/ai-vault/runtime-session-scanner.ts | 7 +- src/main/ai-vault/session-list-results.ts | 23 + src/main/index.ts | 5 + src/main/ipc/ai-vault.ts | 57 +-- src/main/runtime/orca-runtime.test.ts | 220 +++++++++ src/main/runtime/orca-runtime.ts | 68 ++- src/main/runtime/rpc/methods/ai-vault.test.ts | 253 +++++++++-- src/main/runtime/rpc/methods/ai-vault.ts | 51 ++- src/main/runtime/runtime-rpc.ts | 3 + .../right-sidebar/ai-vault-session-display.ts | 121 +---- .../right-sidebar/ai-vault-session-filters.ts | 295 +----------- .../ai-vault-session-projects.ts | 12 +- ...al-ime-candidate-key-release-guard.test.ts | 24 +- .../terminal-ime-composition-tracker.test.ts | 5 +- .../xterm-bypass-policy-non-mac.test.ts | 27 +- .../src/lib/ai-vault-resume-command.test.ts | 21 + .../src/lib/ai-vault-resume-command.ts | 2 + src/shared/ai-vault-session-display.ts | 112 +++++ src/shared/ai-vault-session-filters.test.ts | 116 +++++ src/shared/ai-vault-session-filters.ts | 298 +++++++++++++ src/shared/ai-vault-types.ts | 5 + src/shared/protocol-version.ts | 8 +- src/shared/runtime-types.ts | 8 + .../e2e/chinese-ime-chat-input-repro.spec.ts | 4 +- 61 files changed, 5199 insertions(+), 655 deletions(-) create mode 100644 mobile/app/h/[hostId]/agent-history/[worktreeId].tsx create mode 100644 mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx create mode 100644 mobile/src/agent-history/MobileAgentSessionHistoryList.tsx create mode 100644 mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx create mode 100644 mobile/src/agent-history/agent-history-capability.ts create mode 100644 mobile/src/agent-history/agent-history-current-worktree-badge.ts create mode 100644 mobile/src/agent-history/agent-history-resume-target.test.ts create mode 100644 mobile/src/agent-history/agent-history-resume-target.ts create mode 100644 mobile/src/agent-history/agent-history-scope-paths.test.ts create mode 100644 mobile/src/agent-history/agent-history-scope-paths.ts create mode 100644 mobile/src/agent-history/agent-history-sections.ts create mode 100644 mobile/src/agent-history/agent-history-session-card.test.ts create mode 100644 mobile/src/agent-history/agent-history-session-card.ts create mode 100644 mobile/src/agent-history/agent-history-session-worktree.test.ts create mode 100644 mobile/src/agent-history/agent-history-session-worktree.ts create mode 100644 mobile/src/agent-history/agent-history-styles.ts create mode 100644 mobile/src/agent-history/use-mobile-agent-history-state.ts create mode 100644 mobile/src/agent-history/worktree-navigation-actions.ts create mode 100644 mobile/src/session/MobileSessionHeaderIconButton.tsx create mode 100644 mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx create mode 100644 mobile/src/session/ai-vault-resume-launch.test.ts create mode 100644 mobile/src/session/ai-vault-resume-launch.ts create mode 100644 mobile/src/worktree/workspace-list-ordering.ts create mode 100644 src/main/ai-vault/cached-session-list.ts create mode 100644 src/shared/ai-vault-session-display.ts create mode 100644 src/shared/ai-vault-session-filters.test.ts create mode 100644 src/shared/ai-vault-session-filters.ts diff --git a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx new file mode 100644 index 00000000000..50c62205cd7 --- /dev/null +++ b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx @@ -0,0 +1,18 @@ +import { useLocalSearchParams } from 'expo-router' +import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' + +export default function MobileAgentSessionHistoryScreen() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + }>() + return ( + + ) +} diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 71bda7b64dc..7fb6a1f6eea 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -14,7 +14,6 @@ import { Search, X, Pin, - GitBranch, List, SlidersHorizontal, Layers, @@ -52,6 +51,7 @@ import { useActiveWorktreeScroll } from '../../../src/hooks/use-active-worktree- import type { RepoIcon } from '../../../../src/shared/repo-icon' import { PickerModal } from '../../../src/components/PickerModal' import { ActionSheetContent } from '../../../src/components/ActionSheetModal' +import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' @@ -182,6 +182,7 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) + const [hostCapabilities, setHostCapabilities] = useState([]) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -513,6 +514,10 @@ export function HostScreen({ // format is in place to flip a switch in a future release. useEffect(() => { if (connState !== 'connected' || !client) { + // Why: drop the prior host's capabilities while disconnected/switching so + // a capability-gated action (e.g. Agent Session History) can't linger for + // a host that doesn't support it. + setHostCapabilities([]) return } let cancelled = false @@ -524,9 +529,13 @@ export function HostScreen({ return } if (!response.ok) { + setHostCapabilities([]) return } - const status = (response as RpcSuccess).result as DesktopStatus + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + setHostCapabilities(status.capabilities ?? []) const verdict = evaluateCompat({ desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion @@ -1359,20 +1368,14 @@ export function HostScreen({ actions={ actionTarget ? [ - { - label: 'Source Control', - icon: GitBranch, - onPress: () => { - const params = new URLSearchParams({ - name: actionTarget.displayName || actionTarget.repo, - origin: 'host' - }) - navigateFromHostList( - `/h/${hostId}/source-control/${encodeURIComponent(actionTarget.worktreeId)}?${params.toString()}` - ) - setActionTarget(null) - } - }, + ...buildWorktreeNavigationActions({ + hostId, + worktreeId: actionTarget.worktreeId, + worktreeName: actionTarget.displayName || actionTarget.repo, + hostCapabilities, + navigate: navigateFromHostList, + onDone: () => setActionTarget(null) + }), { label: 'Sleep', icon: Moon, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 2043f28e1a9..b881ddebabb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -36,9 +36,9 @@ import { GitBranch, Globe, Keyboard as KeyboardIcon, - ListChecks, MessageSquare, Monitor, + MoreHorizontal, Plus, RefreshCw, Send, @@ -71,10 +71,14 @@ import { type ActivePanel, canDockSessionPanel, resolvePanelAction, + shouldShowSessionHeaderChecksAction, panelRouteDescriptor } from '../../../../src/session/session-panel-host' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' +import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton' +import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet' +import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' @@ -940,6 +944,7 @@ export default function SessionScreen() { useState('idle') const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) + const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false) const [actionTarget, setActionTarget] = useState(null) const [markdownActionTarget, setMarkdownActionTarget] = useState(null) + // Why: hosts without aiVault.v1 reject aiVault.listSessions, so the header + // entry stays hidden there (mirrors the gated host-list action) instead of + // opening a dead-end "update this host" panel. + const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( + null + ) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -2416,6 +2427,7 @@ export default function SessionScreen() { useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) + setAgentSessionHistorySupported(null) return } let stale = false @@ -2429,10 +2441,14 @@ export default function SessionScreen() { setBrowserScreencastSupported( status.capabilities?.includes('browser.screencast.v1') === true ) + setAgentSessionHistorySupported( + status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true + ) }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) + setAgentSessionHistorySupported(false) } }) return () => { @@ -4451,6 +4467,19 @@ export default function SessionScreen() { }) } + const openAgentSessionHistory = () => { + const params = new URLSearchParams({ name: worktreeName || '' }) + router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`) + } + const showAgentSessionHistoryAction = + !isFolderWorkspaceRoute && agentSessionHistorySupported === true + const showChecksAction = shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute, + repoContextLoaded: prRepoContextLoaded, + hostedChecksSupported: prIsGithubRepo + }) + const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction + return ( @@ -4486,45 +4515,27 @@ export default function SessionScreen() { - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'files' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('files')} - hitSlop={8} + - - + icon={Folder} + onPress={() => handlePanelTap('files')} + /> {!isFolderWorkspaceRoute && ( - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'sourceControl' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('sourceControl')} - hitSlop={8} + - - + icon={GitBranch} + onPress={() => handlePanelTap('sourceControl')} + /> )} - {prRepoContextLoaded && prIsGithubRepo ? ( - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'pr' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('pr')} - hitSlop={8} - accessibilityLabel="Open pull request" - > - - + {showHeaderMoreButton ? ( + setShowHeaderMoreActions(true)} + /> ) : null} @@ -5141,6 +5152,15 @@ export default function SessionScreen() { + handlePanelTap('pr')} + onClose={() => setShowHeaderMoreActions(false)} + /> + + diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx new file mode 100644 index 00000000000..75a4409d5a0 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx @@ -0,0 +1,48 @@ +import Svg, { Path } from 'react-native-svg' + +type MobileAgentSessionHistoryIconProps = { + size?: number + color?: string + strokeWidth?: number +} + +export function MobileAgentSessionHistoryIcon({ + size = 18, + color = '#000000', + strokeWidth = 2 +}: MobileAgentSessionHistoryIconProps) { + return ( + + {/* Why: desktop uses Tabler's category glyph for Agent Session History. */} + + + + + + + ) +} diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx new file mode 100644 index 00000000000..9bc7ceedd91 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx @@ -0,0 +1,177 @@ +import { useCallback, useMemo, useState } from 'react' +import { ActivityIndicator, Pressable, RefreshControl, SectionList, Text, View } from 'react-native' +import { Play } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import { recentSessionConversationTurns } from '../../../src/shared/ai-vault-session-display' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { MobileAgentHistorySection } from './agent-history-sections' +import type { MobileAgentHistoryCard } from './agent-history-session-card' +import { styles } from './agent-history-styles' + +// Lazy-render at most this many preview turns when a card is tapped — the +// scanner already bounds preview text, but rendering them only on tap keeps the +// list cheap. +const PREVIEW_TURN_LIMIT = 5 + +type Props = { + sections: MobileAgentHistorySection[] + sessionsById: ReadonlyMap + refreshing: boolean + showCurrentWorktreeBadges: boolean + resumeActionStateBySessionId?: ReadonlyMap + onResume?: (session: AiVaultSession) => void | Promise + onRefresh: () => void +} + +export function MobileAgentSessionHistoryList({ + sections, + sessionsById, + refreshing, + showCurrentWorktreeBadges, + resumeActionStateBySessionId, + onResume, + onRefresh +}: Props) { + const [expandedId, setExpandedId] = useState(null) + + const toggleExpanded = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + }, []) + + const renderItem = useCallback( + ({ item }: { item: MobileAgentHistoryCard }) => ( + toggleExpanded(item.id)} + /> + ), + [ + expandedId, + onResume, + resumeActionStateBySessionId, + sessionsById, + showCurrentWorktreeBadges, + toggleExpanded + ] + ) + + return ( + card.id} + stickySectionHeadersEnabled={false} + contentContainerStyle={styles.list} + refreshControl={ + + } + renderSectionHeader={({ section }) => ( + + + {section.label} + + {section.data.length} + + )} + renderItem={renderItem} + /> + ) +} + +function AgentHistoryCardRow({ + card, + expanded, + session, + showCurrentWorktreeBadge, + resumeActionState, + onResume, + onPress +}: { + card: MobileAgentHistoryCard + expanded: boolean + session: AiVaultSession | null + showCurrentWorktreeBadge: boolean + resumeActionState?: { disabled: boolean; loading: boolean } + onResume?: (session: AiVaultSession) => void | Promise + onPress: () => void +}) { + const previewTurns = useMemo( + () => (expanded && session ? recentSessionConversationTurns(session, PREVIEW_TURN_LIMIT) : []), + [expanded, session] + ) + + return ( + [styles.card, pressed && styles.cardPressed]} + onPress={onPress} + > + + + + {card.title} + + {card.timeAgo ? {card.timeAgo} : null} + + {card.lastMessage ? ( + + {card.lastMessage} + + ) : null} + + {card.agentLabel} + + {card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'} + + {showCurrentWorktreeBadge && card.isCurrentWorktree ? ( + + current worktree + + ) : null} + {session && onResume ? ( + [ + styles.resumeButton, + resumeActionState?.disabled && styles.resumeButtonDisabled, + pressed && !resumeActionState?.disabled && styles.resumeButtonPressed + ]} + onPress={(event) => { + event.stopPropagation() + if (!resumeActionState?.disabled) { + void onResume(session) + } + }} + disabled={resumeActionState?.disabled} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Resume agent session" + > + {resumeActionState?.loading ? ( + + ) : ( + + )} + + ) : null} + + {expanded && previewTurns.length > 0 ? ( + + {previewTurns.map((turn, index) => ( + + {turn.role} + {turn.text} + + ))} + + ) : null} + + ) +} diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx new file mode 100644 index 00000000000..8879b783687 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -0,0 +1,421 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, RefreshCw } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { useHostClient } from '../transport/client-context' +import type { RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { getWorktreeLabel } from '../session/worktree-label' +import { + buildMobileAiVaultResumeLaunch, + createMobileAiVaultResumeMutationRegistry, + readMobileRuntimeHostPlatform, + readMobileRuntimeTerminalWindowsShell, + resolveMobileAiVaultResumePlatform, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS, + type MobileAiVaultResumeSettings +} from '../session/ai-vault-resume-launch' +import { triggerError, triggerSuccess } from '../platform/haptics' +import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' +import { buildMobileAgentHistorySections } from './agent-history-sections' +import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' +import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList' +import { + resolveMobileAiVaultSessionResumeTarget, + type MobileAiVaultResumeFolderWorkspace, + type MobileAiVaultResumeProjectGroup, + type MobileAiVaultResumeRepo +} from './agent-history-resume-target' +import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' +import { styles } from './agent-history-styles' + +export type MobileAgentSessionHistoryPanelProps = { + hostId: string + worktreeId: string + name?: string +} + +const SCOPE_TABS: { scope: AiVaultScope; label: string }[] = [ + { scope: 'workspace', label: 'Workspace' }, + { scope: 'project', label: 'Project' }, + { scope: 'all', label: 'All' } +] + +export function MobileAgentSessionHistoryPanel({ + hostId, + worktreeId, + name = '' +}: MobileAgentSessionHistoryPanelProps) { + const router = useRouter() + const { client, state: connState } = useHostClient(hostId) + const [worktrees, setWorktrees] = useState([]) + const [worktreesLoaded, setWorktreesLoaded] = useState(false) + const [query, setQuery] = useState('') + const [resumingSessionId, setResumingSessionId] = useState(null) + const [resumeMessage, setResumeMessage] = useState(null) + const resumeLaunchInFlightRef = useRef(false) + const resumeMutationRegistryRef = useRef( + createMobileAiVaultResumeMutationRegistry(createMobileAiVaultResumeMutationId) + ) + const worktreeLabel = getWorktreeLabel(name, worktreeId) + + // Why: the worktree list seeds the host-local scopePaths derivation and the + // active-worktree path for the "current worktree" badge. + useEffect(() => { + if (!client || connState !== 'connected') { + return + } + let cancelled = false + void (async () => { + try { + const worktreeResponse = await client.sendRequest('worktree.ps', { limit: 10000 }) + if (cancelled) { + return + } + if (worktreeResponse.ok) { + const result = (worktreeResponse as RpcSuccess).result as { worktrees: Worktree[] } + setWorktrees(result.worktrees) + } + } catch { + // Why: worktree list is best-effort context; the session scan still runs + // (without it, scoped tabs can't narrow and fall back to the full list). + } finally { + // Why: mark loaded even on failure so a scoped tab proceeds with an + // unscoped fetch instead of holding a spinner forever. + if (!cancelled) { + setWorktreesLoaded(true) + } + } + })() + return () => { + cancelled = true + } + }, [client, connState]) + + const { + scope, + screenState, + refreshing, + hostStatusResult, + activeWorktreePath, + scopeFilterPaths, + onSelectScope, + onRefresh, + retry + } = useMobileAgentHistoryState({ hostId, worktreeId, worktrees, worktreesLoaded }) + + const sessions = screenState.kind === 'ready' ? screenState.sessions : EMPTY_SESSIONS + const issues = screenState.kind === 'ready' ? screenState.issues : EMPTY_ISSUES + const sessionsById = useMemo( + () => new Map(sessions.map((session) => [session.id, session])), + [sessions] + ) + const sections = useMemo( + () => + buildMobileAgentHistorySections(sessions, { + query, + scope, + scopeFilterPaths, + activeWorktreePath, + now: Date.now() + }), + [sessions, query, scope, scopeFilterPaths, activeWorktreePath] + ) + + const hostPlatform = useMemo( + () => readMobileRuntimeHostPlatform(hostStatusResult), + [hostStatusResult] + ) + const hostTerminalWindowsShell = useMemo( + () => readMobileRuntimeTerminalWindowsShell(hostStatusResult), + [hostStatusResult] + ) + + const resumeActionStateBySessionId = useMemo( + () => buildMobileAgentHistoryResumeActionState(sessions, resumingSessionId), + [resumingSessionId, sessions] + ) + + const onResumeSession = useCallback( + async (session: AiVaultSession): Promise => { + if (resumeLaunchInFlightRef.current) { + return + } + if (!client || connState !== 'connected') { + setResumeMessage('Waiting for host...') + triggerError() + return + } + if (!session.sessionId) { + setResumeMessage('This session is missing a resume id.') + triggerError() + return + } + + resumeLaunchInFlightRef.current = true + setResumingSessionId(session.id) + setResumeMessage(null) + try { + const { + repos, + folderWorkspaces, + projectGroups, + settings, + worktrees: freshWorktrees + } = await loadMobileResumeMetadata(client) + const target = resolveMobileAiVaultSessionResumeTarget({ + session, + activeWorktreeId: worktreeId, + // Why: resolve against live worktrees so a workspace deleted or + // archived since panel mount can't be picked; the mount-time list is + // only a fallback when the fresh fetch fails. + worktrees: freshWorktrees ?? worktrees, + repos, + folderWorkspaces, + projectGroups + }) + if (target.status !== 'ready') { + setResumeMessage(target.message) + triggerError() + return + } + + const platform = resolveMobileAiVaultResumePlatform( + target.targetStatus, + hostPlatform, + target.workspacePath, + target.terminalPlatform + ) + if (!platform) { + setResumeMessage('Unable to determine host platform.') + triggerError() + return + } + + const launch = buildMobileAiVaultResumeLaunch({ + session, + hostPlatform: platform, + hostTerminalWindowsShell, + settings + }) + await resumeAiVaultSessionInTerminal(client, target.worktreeId, { + ...launch, + clientMutationId: resumeMutationRegistryRef.current.claim(session.id) + }) + resumeMutationRegistryRef.current.releaseOnSuccess(session.id) + triggerSuccess() + setResumeMessage('Agent session queued.') + router.push( + `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters< + typeof router.push + >[0] + ) + } catch (err) { + triggerError() + setResumeMessage(err instanceof Error ? err.message : 'Failed to resume session.') + } finally { + resumeLaunchInFlightRef.current = false + setResumingSessionId(null) + } + }, + [ + client, + connState, + hostId, + hostPlatform, + hostTerminalWindowsShell, + router, + worktreeId, + worktrees + ] + ) + + return ( + + + + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => router.back()} + hitSlop={8} + accessibilityLabel="Back" + > + + + + + Agent Session History + + + {worktreeLabel} + + + [styles.refreshButton, pressed && styles.refreshButtonPressed]} + onPress={() => void onRefresh()} + hitSlop={8} + accessibilityLabel="Refresh agent sessions" + > + + + + + + {screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'unsupported' ? ( + + Agent Session History Unavailable + + Update Orca on this host to browse agent session history. + + + ) : screenState.kind === 'error' ? ( + + Unable to Load + {screenState.message} + + Retry + + + ) : ( + <> + + {SCOPE_TABS.map((tab) => { + const active = scope === tab.scope + return ( + onSelectScope(tab.scope)} + > + + {tab.label} + + + ) + })} + + + + + {issues.length > 0 ? ( + + + {issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped + + + ) : null} + {resumeMessage ? ( + + {resumeMessage} + + ) : null} + {sections.length === 0 ? ( + + No agent sessions + + {query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'} + + + ) : ( + void onRefresh()} + /> + )} + + )} + + ) +} + +const EMPTY_SESSIONS: AiVaultSession[] = [] +const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = [] + +async function loadMobileResumeMetadata(client: Pick): Promise<{ + repos: MobileAiVaultResumeRepo[] + folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] + projectGroups: MobileAiVaultResumeProjectGroup[] + settings: MobileAiVaultResumeSettings | null + worktrees: Worktree[] | null +}> { + // Why: repo.list can enrich repo remote identities, so fetch resume-only + // metadata after explicit user intent instead of delaying history browsing. + // timeoutMs: without it a socket drop parks these on the reconnect waiter + // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). + const [ + repoResponse, + folderWorkspaceResponse, + projectGroupResponse, + settingsResponse, + worktreeResponse + ] = await Promise.all([ + client.sendRequest('repo.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), + client + .sendRequest('folderWorkspace.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('projectGroup.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('settings.get', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('worktree.ps', { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null) + ]) + if (!repoResponse.ok) { + throw new Error(repoResponse.error?.message || 'Unable to load workspace metadata.') + } + const repoResult = repoResponse.result as { repos?: MobileAiVaultResumeRepo[] } + const folderWorkspaceResult = + folderWorkspaceResponse?.ok === true + ? (folderWorkspaceResponse.result as { + folderWorkspaces?: MobileAiVaultResumeFolderWorkspace[] + }) + : null + const projectGroupResult = + projectGroupResponse?.ok === true + ? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] }) + : null + const settingsResult = + settingsResponse?.ok === true + ? (settingsResponse.result as { settings?: MobileAiVaultResumeSettings }) + : null + const worktreeResult = + worktreeResponse?.ok === true ? (worktreeResponse.result as { worktrees?: Worktree[] }) : null + return { + repos: repoResult.repos ?? [], + folderWorkspaces: folderWorkspaceResult?.folderWorkspaces ?? [], + projectGroups: projectGroupResult?.groups ?? [], + settings: settingsResult?.settings ?? null, + worktrees: worktreeResult?.worktrees ?? null + } +} + +function createMobileAiVaultResumeMutationId(sessionId: string): string { + const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session' + const randomPart = Math.random().toString(36).slice(2, 10) + return `ai-vault-resume:${sessionPart}:${Date.now().toString(36)}:${randomPart}` +} diff --git a/mobile/src/agent-history/agent-history-capability.ts b/mobile/src/agent-history/agent-history-capability.ts new file mode 100644 index 00000000000..28db8f48e2e --- /dev/null +++ b/mobile/src/agent-history/agent-history-capability.ts @@ -0,0 +1,4 @@ +// Why: mobile's protocol-version.ts is a separate copy without the capability +// constants (mirrors MOBILE_TASKS_CAPABILITY in tasks.tsx). Keep this string in +// lockstep with src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY. +export const MOBILE_AI_VAULT_CAPABILITY = 'aiVault.v1' diff --git a/mobile/src/agent-history/agent-history-current-worktree-badge.ts b/mobile/src/agent-history/agent-history-current-worktree-badge.ts new file mode 100644 index 00000000000..4dbcae4d6db --- /dev/null +++ b/mobile/src/agent-history/agent-history-current-worktree-badge.ts @@ -0,0 +1,7 @@ +import type { AiVaultScope } from '../../../src/shared/ai-vault-types' + +export function shouldShowMobileCurrentWorktreeBadge(scope: AiVaultScope): boolean { + // Why: Workspace is already the current-worktree-only view; Project and All + // still mix in sibling/other worktrees, so the badge remains useful there. + return scope !== 'workspace' +} diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts new file mode 100644 index 00000000000..d59feb98e15 --- /dev/null +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from 'vitest' +import { + getMobileAiVaultResumeRepoTargetStatus, + getMobileAiVaultResumeWorktreeTargetStatus, + isSupportedMobileAiVaultResumeTargetStatus, + mobileAiVaultResumeTargetBlockMessage, + resolveMobileAiVaultSessionResumeTarget +} from './agent-history-resume-target' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Resume target', + cwd: '/Users/ada/repo/app', + branch: null, + model: null, + filePath: '/Users/ada/.claude/session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-06-29T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +function worktree(overrides: Partial & { worktreeId: string; path: string }): Worktree { + return { + repoId: 'local-repo', + repo: 'orca', + branch: 'main', + displayName: overrides.worktreeId, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +describe('mobile AI Vault resume target guards', () => { + const worktrees = [ + { worktreeId: 'local-wt', repoId: 'local-repo' }, + { worktreeId: 'ssh-wt', repoId: 'ssh-repo' }, + { worktreeId: 'runtime-wt', repoId: 'runtime-repo' }, + { worktreeId: 'host-owned-wt', repoId: 'local-repo', hostId: 'ssh:builder' as const }, + { + worktreeId: 'folder:folder-local', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace' as const + }, + { + worktreeId: 'folder:folder-ssh', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace' as const + }, + { + worktreeId: 'folder:folder-runtime', + repoId: 'folder-workspace:group-runtime', + workspaceKind: 'folder-workspace' as const + } + ] + const repos = [ + { id: 'local-repo', path: '/Users/ada/repo', connectionId: null }, + { id: 'ssh-repo', path: '/home/ada/ssh-repo', connectionId: 'builder' }, + { + id: 'runtime-repo', + path: '/workspace/runtime', + connectionId: null, + executionHostId: 'runtime:devbox' as const + } + ] + const folderWorkspaces = [ + { id: 'folder-local', projectGroupId: 'group-local', folderPath: '/Users/ada/folder' }, + { + id: 'folder-ssh', + projectGroupId: 'group-local', + folderPath: '/home/ada/folder', + connectionId: 'folder-builder' + }, + { id: 'folder-runtime', projectGroupId: 'group-runtime', folderPath: '/workspace/folder' } + ] + const projectGroups = [ + { id: 'group-local', connectionId: null }, + { id: 'group-runtime', executionHostId: 'runtime:devbox' as const } + ] + + it('classifies local, SSH, runtime, and unknown repos', () => { + expect(getMobileAiVaultResumeRepoTargetStatus(repos[0])).toBe('local') + expect(getMobileAiVaultResumeRepoTargetStatus(repos[1])).toBe('ssh') + expect(getMobileAiVaultResumeRepoTargetStatus(repos[2])).toBe('runtime') + expect(getMobileAiVaultResumeRepoTargetStatus(null)).toBe('unknown') + }) + + it('classifies worktrees from their repo, with worktree host taking precedence', () => { + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'local-wt', worktrees, repos }) + ).toBe('local') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'ssh-wt', worktrees, repos }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'runtime-wt', worktrees, repos }) + ).toBe('runtime') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'host-owned-wt', worktrees, repos }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-local', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('local') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-ssh', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-runtime', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('runtime') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'missing', worktrees, repos }) + ).toBe('unknown') + }) + + it('supports local targets only; SSH hosts cannot see host-local transcripts', () => { + expect(isSupportedMobileAiVaultResumeTargetStatus('local')).toBe(true) + expect(isSupportedMobileAiVaultResumeTargetStatus('ssh')).toBe(false) + expect(isSupportedMobileAiVaultResumeTargetStatus('runtime')).toBe(false) + expect(mobileAiVaultResumeTargetBlockMessage('runtime')).toContain('runtime-hosted') + expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('SSH workspace') + expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('local workspace') + expect(mobileAiVaultResumeTargetBlockMessage('unknown')).toContain('local workspace') + }) + + it('resolves project-scope rows to the matched session worktree before the route worktree', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/repo/feature/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ worktreeId: 'session-wt', path: '/Users/ada/repo/feature' }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'session-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/feature', + terminalPlatform: null + }) + }) + + it('falls back to the active route worktree when the session worktree is archived', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/repo/archive/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ + worktreeId: 'archived-wt', + path: '/Users/ada/repo/archive', + isArchived: true + }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'route-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/main', + terminalPlatform: null + }) + }) + + it('blocks runtime targets when no supported fallback is available', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/runtime/app' }), + activeWorktreeId: 'runtime-wt', + worktrees: [ + worktree({ worktreeId: 'runtime-wt', repoId: 'runtime-repo', path: '/Users/ada/runtime' }) + ], + repos + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted') + }) + + it('blocks SSH folder workspace targets because transcripts are host-local', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/folder/src' }), + activeWorktreeId: 'folder:folder-ssh', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-ssh', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/home/ada/folder' + }) + ], + repos, + folderWorkspaces, + projectGroups + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + }) + + it('skips an SSH session-worktree candidate in favor of a local active-worktree fallback', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/ssh-repo/feature/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ + worktreeId: 'ssh-session-wt', + repoId: 'ssh-repo', + path: '/home/ada/ssh-repo/feature' + }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'route-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/main', + terminalPlatform: null + }) + }) + + it('blocks SSH session worktrees with the SSH message when no local fallback exists', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/ssh-repo/feature/src' }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [ + worktree({ + worktreeId: 'ssh-session-wt', + repoId: 'ssh-repo', + path: '/home/ada/ssh-repo/feature' + }) + ], + repos + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + }) + + it('blocks folder workspaces whose candidate repos include a runtime owner', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/workspace/folder/src' }), + activeWorktreeId: 'folder:folder-runtime-candidate', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-runtime-candidate', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/workspace/folder' + }) + ], + repos: [ + ...repos, + { + id: 'runtime-in-folder', + path: '/workspace/folder/repo', + connectionId: null, + executionHostId: 'runtime:devbox' as const + } + ], + folderWorkspaces: [ + { + id: 'folder-runtime-candidate', + projectGroupId: 'group-local', + folderPath: '/workspace/folder' + } + ], + projectGroups + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted') + }) + + it('blocks folder workspaces when folder metadata is unavailable', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/folder/src' }), + activeWorktreeId: 'folder:missing-folder', + worktrees: [ + worktree({ + worktreeId: 'folder:missing-folder', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/Users/ada/folder' + }) + ], + repos, + folderWorkspaces: [], + projectGroups + }) + expect(target).toEqual({ + status: 'blocked', + message: 'Open a local workspace before resuming a session.' + }) + }) + + it('blocks folder workspaces with mixed candidate repo hosts as unknown', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/mixed/src' }), + activeWorktreeId: 'folder:folder-mixed', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-mixed', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/Users/ada/mixed' + }) + ], + repos: [ + { + id: 'local-in-folder', + path: '/Users/ada/mixed/local', + connectionId: null + }, + { + id: 'ssh-in-folder', + path: '/Users/ada/mixed/ssh', + connectionId: 'builder' + } + ], + folderWorkspaces: [ + { + id: 'folder-mixed', + projectGroupId: 'group-local', + folderPath: '/Users/ada/mixed' + } + ], + projectGroups + }) + expect(target).toEqual({ + status: 'blocked', + message: 'Open a local workspace before resuming a session.' + }) + }) +}) diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts new file mode 100644 index 00000000000..a63a7ca92c7 --- /dev/null +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -0,0 +1,304 @@ +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../src/shared/execution-host' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import type { Worktree } from '../worktree/workspace-list-types' +import { + canResumeInMobileSessionWorktree, + resolveMobileAgentHistorySessionWorktree +} from './agent-history-session-worktree' + +export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' + +export type MobileAiVaultResumeRepo = { + id: string + path?: string | null + projectGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +type MobileAiVaultResumeWorktree = Pick & { + path?: string | null + workspaceKind?: Worktree['workspaceKind'] + hostId?: ExecutionHostId | null +} + +export type MobileAiVaultResumeFolderWorkspace = { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null +} + +export type MobileAiVaultResumeProjectGroup = { + id: string + parentGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | string | null +} + +export type MobileAiVaultSessionResumeTarget = + | { + status: 'ready' + worktreeId: string + targetStatus: 'local' + workspacePath: string | null + terminalPlatform: NodeJS.Platform | null + } + | { status: 'blocked'; message: string } + +export function getMobileAiVaultResumeRepoTargetStatus( + repo: MobileAiVaultResumeRepo | null | undefined +): MobileAiVaultResumeTargetStatus { + if (!repo) { + return 'unknown' + } + return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo)) +} + +export function getMobileAiVaultResumeWorktreeTargetStatus(args: { + worktreeId: string | null + worktrees: readonly MobileAiVaultResumeWorktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups?: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + if (!args.worktreeId) { + return 'unknown' + } + const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId) + if (!worktree) { + return 'unknown' + } + if (worktree.workspaceKind === 'folder-workspace') { + return getMobileAiVaultResumeFolderTargetStatus({ + worktreeId: args.worktreeId, + worktree, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces ?? [], + projectGroups: args.projectGroups ?? [] + }) + } + const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId) + if (worktreeHost !== 'unknown') { + return worktreeHost + } + return getMobileAiVaultResumeRepoTargetStatus( + args.repos.find((candidate) => candidate.id === worktree.repoId) + ) +} + +export function isSupportedMobileAiVaultResumeTargetStatus( + status: MobileAiVaultResumeTargetStatus +): status is 'local' { + // Why: mobile sessions come from the host-local transcript scan, so an SSH + // workspace cannot see the transcript file (mirror of desktop #6270). + return status === 'local' +} + +export function mobileAiVaultResumeTargetBlockMessage( + status: MobileAiVaultResumeTargetStatus +): string { + if (status === 'runtime') { + return 'Resume from history is not available in runtime-hosted workspaces.' + } + if (status === 'ssh') { + return 'This session is stored on the host machine, so it cannot be resumed in an SSH workspace. Open a local workspace for this project.' + } + return 'Open a local workspace before resuming a session.' +} + +export function resolveMobileAiVaultSessionResumeTarget(args: { + session: AiVaultSession + activeWorktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups?: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultSessionResumeTarget { + const sessionWorktree = resolveMobileAgentHistorySessionWorktree({ + session: args.session, + worktrees: args.worktrees, + activeWorktreeId: args.activeWorktreeId + }) + const sessionWorktreeId = canResumeInMobileSessionWorktree(sessionWorktree) + ? sessionWorktree?.worktreeId + : null + const candidateWorktreeIds = [ + sessionWorktreeId, + args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId + ? args.activeWorktreeId + : null + ].filter((candidate): candidate is string => Boolean(candidate)) + + for (const candidateWorktreeId of candidateWorktreeIds) { + const targetStatus = getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: candidateWorktreeId, + worktrees: args.worktrees, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces, + projectGroups: args.projectGroups + }) + if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) { + continue + } + return { + status: 'ready', + worktreeId: candidateWorktreeId, + targetStatus, + workspacePath: + args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ?? + null, + terminalPlatform: + args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId) + ?.terminalPlatform ?? null + } + } + + const blockedStatus = getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: candidateWorktreeIds[0] ?? args.activeWorktreeId, + worktrees: args.worktrees, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces, + projectGroups: args.projectGroups + }) + return { status: 'blocked', message: mobileAiVaultResumeTargetBlockMessage(blockedStatus) } +} + +function getMobileAiVaultResumeFolderTargetStatus(args: { + worktreeId: string + worktree: MobileAiVaultResumeWorktree + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + const folderWorkspaceId = args.worktreeId.startsWith('folder:') + ? args.worktreeId.slice('folder:'.length) + : null + const folderWorkspace = folderWorkspaceId + ? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId) + : null + if (!folderWorkspace) { + return 'unknown' + } + const projectGroupId = + folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId) + const projectGroup = projectGroupId + ? args.projectGroups.find((group) => group.id === projectGroupId) + : null + + const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId) + if (groupHostId) { + return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId) + } + + const explicitConnectionId = ( + folderWorkspace?.connectionId ?? + projectGroup?.connectionId ?? + '' + ).trim() + if (explicitConnectionId) { + return getMobileAiVaultResumeExecutionHostTargetStatus( + toSshExecutionHostId(explicitConnectionId) + ) + } + + return mergeMobileAiVaultResumeExecutionHostTargetStatuses( + getMobileFolderWorkspaceCandidateRepos({ + folderWorkspace, + projectGroupId, + projectGroups: args.projectGroups, + repos: args.repos + }).map(getRepoExecutionHostId) + ) +} + +function getMobileAiVaultResumeExecutionHostTargetStatus( + hostId: ExecutionHostId | null | undefined +): MobileAiVaultResumeTargetStatus { + const parsed = parseExecutionHostId(hostId) + if (!parsed) { + return 'unknown' + } + return parsed.kind +} + +function parseFolderWorkspaceRepoId(repoId: string): string | null { + const prefix = 'folder-workspace:' + return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null +} + +function getMobileFolderWorkspaceCandidateRepos(args: { + folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined + projectGroupId: string | null + projectGroups: readonly MobileAiVaultResumeProjectGroup[] + repos: readonly MobileAiVaultResumeRepo[] +}): MobileAiVaultResumeRepo[] { + if (!args.folderWorkspace || !args.projectGroupId) { + return [] + } + const folderWorkspace = args.folderWorkspace + const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + typeof repo.path === 'string' && + repo.path.trim().length > 0 && + isPathInsideOrEqual(folderWorkspace.folderPath, repo.path) + ) + if (folderWorkspace.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) + ] +} + +function getMobileProjectGroupSubtreeIds( + projectGroups: readonly MobileAiVaultResumeProjectGroup[], + projectGroupId: string +): Set { + const ids = new Set([projectGroupId]) + let changed = true + while (changed) { + changed = false + for (const group of projectGroups) { + if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) { + ids.add(group.id) + changed = true + } + } + } + return ids +} + +function mergeMobileAiVaultResumeExecutionHostTargetStatuses( + hostIds: readonly ExecutionHostId[] +): MobileAiVaultResumeTargetStatus { + if (hostIds.length === 0) { + return 'local' + } + const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus) + const uniqueStatuses = new Set(statuses) + if (uniqueStatuses.has('runtime')) { + return 'runtime' + } + return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' +} diff --git a/mobile/src/agent-history/agent-history-scope-paths.test.ts b/mobile/src/agent-history/agent-history-scope-paths.test.ts new file mode 100644 index 00000000000..59f16ec9ef8 --- /dev/null +++ b/mobile/src/agent-history/agent-history-scope-paths.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../worktree/workspace-list-types' +import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths' + +function worktree(overrides: Partial): Pick { + return { + worktreeId: overrides.worktreeId ?? 'w1', + path: overrides.path ?? '/Users/ada/repo/app', + repoId: overrides.repoId ?? 'repo-1' + } +} + +const active = worktree({ worktreeId: 'w1', path: '/Users/ada/repo/app', repoId: 'repo-1' }) +const sibling = worktree({ worktreeId: 'w2', path: '/Users/ada/repo/app-2', repoId: 'repo-1' }) +const otherRepo = worktree({ worktreeId: 'w3', path: '/Users/ada/other/ui', repoId: 'repo-2' }) + +describe('deriveMobileAiVaultScopePaths', () => { + it('workspace scope returns only the active worktree path', () => { + expect( + deriveMobileAiVaultScopePaths('workspace', active, [active, sibling, otherRepo]) + ).toEqual(['/Users/ada/repo/app']) + }) + + it('project scope adds same-repo siblings but not other-repo worktrees', () => { + expect(deriveMobileAiVaultScopePaths('project', active, [active, sibling, otherRepo])).toEqual([ + '/Users/ada/repo/app', + '/Users/ada/repo/app-2' + ]) + }) + + it('all scope returns no scope hints (global recency list)', () => { + expect(deriveMobileAiVaultScopePaths('all', active, [active, sibling])).toEqual([]) + }) + + it('returns no paths when there is no active worktree', () => { + expect(deriveMobileAiVaultScopePaths('workspace', null, [sibling])).toEqual([]) + }) + + it('caps project scope at 64 paths so the host RPC bound does not reject the request', () => { + const siblings = Array.from({ length: 200 }, (_, index) => + worktree({ + worktreeId: `w-sib-${index}`, + path: `/Users/ada/repo/app-${index}`, + repoId: 'repo-1' + }) + ) + const result = deriveMobileAiVaultScopePaths('project', active, [active, ...siblings]) + expect(result.length).toBe(64) + // Active worktree is seeded first, so it survives truncation. + expect(result[0]).toBe('/Users/ada/repo/app') + }) + + it('dedupes and skips non-absolute paths', () => { + const dupe = worktree({ worktreeId: 'w4', path: '/Users/ada/repo/app', repoId: 'repo-1' }) + const relative = worktree({ worktreeId: 'w5', path: 'relative/path', repoId: 'repo-1' }) + expect(deriveMobileAiVaultScopePaths('project', active, [active, dupe, relative])).toEqual([ + '/Users/ada/repo/app' + ]) + }) +}) diff --git a/mobile/src/agent-history/agent-history-scope-paths.ts b/mobile/src/agent-history/agent-history-scope-paths.ts new file mode 100644 index 00000000000..da3b98457d2 --- /dev/null +++ b/mobile/src/agent-history/agent-history-scope-paths.ts @@ -0,0 +1,60 @@ +import { + isRuntimePathAbsolute, + normalizeRuntimePathForComparison +} from '../../../src/shared/cross-platform-path' +import type { Worktree } from '../worktree/workspace-list-types' +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + type AiVaultScope +} from '../../../src/shared/ai-vault-types' + +// Why: the renderer's deriveAiVault* helpers are renderer-located and +// Metro-unresolvable, so mobile does its own minimal derivation seeded by the +// active worktree's path plus same-repo sibling worktrees (mobile already loads +// the full worktree list via worktree.ps). scopePaths only widen the host scan's +// discovery breadth; they are host-local match prefixes, never device paths. +export function deriveMobileAiVaultScopePaths( + scope: AiVaultScope, + activeWorktree: Pick | null, + liveWorktrees: readonly Pick[] +): string[] { + // 'all' scope scans without scope hints — the host returns the global recency + // list, so no scopePaths are needed (and would only narrow discovery). + if (scope === 'all' || !activeWorktree) { + return [] + } + + const paths: string[] = [] + addScopePath(paths, activeWorktree.path) + + // Workspace scope = the active worktree only. Project scope additionally + // covers same-repo sibling worktrees so the project view stays complete. + if (scope === 'project') { + for (const worktree of liveWorktrees) { + // Why: the RPC rejects (does not truncate) oversized scopePaths, and the + // list only widens discovery — dropping tail siblings beats hard-failing. + if (paths.length >= AI_VAULT_SCOPE_PATHS_MAX_COUNT) { + break + } + if (worktree.repoId === activeWorktree.repoId) { + addScopePath(paths, worktree.path) + } + } + } + + return paths +} + +function addScopePath(paths: string[], pathValue: string | undefined): void { + const trimmedPath = pathValue?.trim() + if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { + return + } + const comparisonPath = normalizeRuntimePathForComparison(trimmedPath) + if ( + paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath) + ) { + return + } + paths.push(trimmedPath) +} diff --git a/mobile/src/agent-history/agent-history-sections.ts b/mobile/src/agent-history/agent-history-sections.ts new file mode 100644 index 00000000000..52f76b202d0 --- /dev/null +++ b/mobile/src/agent-history/agent-history-sections.ts @@ -0,0 +1,59 @@ +import { + filterAiVaultSessions, + groupAiVaultSessions +} from '../../../src/shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAgentHistoryCard, + type MobileAgentHistoryCard +} from './agent-history-session-card' + +// Why: `data` (not `cards`) is the field name React Native's SectionList reads +// for each section's rows — naming it anything else renders empty sections. +export type MobileAgentHistorySection = { + key: string + label: string + data: MobileAgentHistoryCard[] +} + +// Why: the host treats scopePaths as a WIDENING union (it adds in-scope sessions +// beyond the recency cap, never restricts), so the Workspace/Project tabs must +// narrow on the client like the desktop panel does. Mobile has no project-key +// metadata, so both scoped tabs narrow by cwd path-prefix: Workspace = the active +// worktree path, Project = the active worktree + same-repo siblings (the same set +// deriveMobileAiVaultScopePaths produces). 'all' applies no path narrowing. +// hideEmptySessions matches the desktop default. +export function buildMobileAgentHistorySections( + sessions: readonly AiVaultSession[], + options: { + query: string + scope: AiVaultScope + scopeFilterPaths: readonly string[] + activeWorktreePath: string | null + now: number + } +): MobileAgentHistorySection[] { + // Why: narrow scoped tabs by cwd path-prefix. When the worktree list hasn't + // loaded yet, scopeFilterPaths is empty — fall back to unnarrowed rather than + // filtering everything out (which would flash an empty list); the memo re-runs + // with real paths once worktree.ps resolves. + const narrowByPath = options.scope !== 'all' && options.scopeFilterPaths.length > 0 + const filtered = filterAiVaultSessions(sessions, { + query: options.query, + agents: AI_VAULT_AGENTS, + scope: narrowByPath ? 'workspace' : 'all', + sort: 'updated', + activeWorktreePaths: narrowByPath ? options.scopeFilterPaths : [], + hideEmptySessions: true + }) + + const groups = groupAiVaultSessions(filtered, 'folder') + return groups.map((group) => ({ + key: group.key, + label: group.label, + data: group.sessions.map((session) => + buildMobileAgentHistoryCard(session, options.activeWorktreePath, options.now) + ) + })) +} diff --git a/mobile/src/agent-history/agent-history-session-card.test.ts b/mobile/src/agent-history/agent-history-session-card.test.ts new file mode 100644 index 00000000000..d38285aee14 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-card.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAgentHistoryResumeActionState, + buildMobileAgentHistoryCard, + isSessionInActiveWorktree +} from './agent-history-session-card' +import { buildMobileAgentHistorySections } from './agent-history-sections' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' +import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' + +const NOW = Date.parse('2026-06-29T00:00:00.000Z') + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement vault filters', + cwd: '/Users/ada/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-06-28T23:00:00.000Z', + updatedAt: '2026-06-28T23:55:00.000Z', + modifiedAt: '2026-06-28T23:55:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [ + { role: 'user', text: 'add the scope tabs', timestamp: null }, + { role: 'assistant', text: 'done — tabs added', timestamp: null } + ], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('buildMobileAgentHistoryCard', () => { + it('maps the card view-model with time-ago, label, and last message', () => { + const card = buildMobileAgentHistoryCard(session(), '/Users/ada/repo/app', NOW) + expect(card.agentLabel).toBe('Claude') + expect(card.timeAgo).toBe('5m') + expect(card.lastMessage).toBe('done — tabs added') + expect(card.messageCount).toBe(4) + expect(card.isCurrentWorktree).toBe(true) + }) + + it('omits the current-worktree badge when cwd is outside the active worktree', () => { + const card = buildMobileAgentHistoryCard(session(), '/Users/ada/other', NOW) + expect(card.isCurrentWorktree).toBe(false) + }) + + it('falls back to a title for empty session titles', () => { + const card = buildMobileAgentHistoryCard(session({ title: '' }), null, NOW) + expect(card.title).toBe('Untitled session') + expect(card.isCurrentWorktree).toBe(false) + }) +}) + +describe('buildMobileAgentHistoryResumeActionState', () => { + it('disables all resume buttons while one session is launching', () => { + const state = buildMobileAgentHistoryResumeActionState( + [session({ id: 'claude:1' }), session({ id: 'codex:2', agent: 'codex' })], + 'codex:2' + ) + expect(state.get('claude:1')).toEqual({ disabled: true, loading: false }) + expect(state.get('codex:2')).toEqual({ disabled: true, loading: true }) + }) + + it('keeps resume buttons enabled when no launch is in flight', () => { + const state = buildMobileAgentHistoryResumeActionState([session({ id: 'claude:1' })], null) + expect(state.get('claude:1')).toEqual({ disabled: false, loading: false }) + }) +}) + +describe('isSessionInActiveWorktree', () => { + it('matches a nested cwd inside the worktree path', () => { + expect( + isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app/src' }, '/Users/ada/repo/app') + ).toBe(true) + }) + + it('is false without a cwd or active path', () => { + expect(isSessionInActiveWorktree({ cwd: null }, '/Users/ada/repo/app')).toBe(false) + expect(isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app' }, null)).toBe(false) + }) +}) + +describe('buildMobileAgentHistorySections', () => { + it('hides empty sessions by default and groups remaining by folder', () => { + const sections = buildMobileAgentHistorySections( + [ + session(), + // Truly empty: no turns, no preview turns, no recoverable signals — + // preview turns alone now count as resumable content and stay visible. + session({ id: 'claude:empty', messageCount: 0, previewMessages: [] }) + ], + { + query: '', + scope: 'all', + scopeFilterPaths: [], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + } + ) + const allCards = sections.flatMap((s) => s.data) + expect(allCards.map((card) => card.id)).toEqual(['claude:1']) + }) + + it('applies the search query across sections', () => { + const sections = buildMobileAgentHistorySections( + [session(), session({ id: 'claude:2', title: 'Repair terminal tabs' })], + { query: 'repair', scope: 'all', scopeFilterPaths: [], activeWorktreePath: null, now: NOW } + ) + expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:2']) + }) + + // Why: the host union widens, so the screen must narrow scoped tabs by cwd + // path-prefix on the client — otherwise Workspace/Project show the same set as All. + it('narrows Workspace scope to sessions whose cwd is inside the active worktree path', () => { + const sessions = [ + session({ id: 'claude:in', cwd: '/Users/ada/repo/app/src' }), + session({ id: 'claude:out', cwd: '/Users/ada/other-repo' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'workspace', + scopeFilterPaths: ['/Users/ada/repo/app'], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:in']) + }) + + it('narrows Project scope to the active worktree plus same-repo sibling paths', () => { + const sessions = [ + session({ id: 'claude:active', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:sibling', cwd: '/Users/ada/repo/app-feature/lib' }), + session({ id: 'claude:foreign', cwd: '/Users/ada/unrelated' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'project', + scopeFilterPaths: ['/Users/ada/repo/app', '/Users/ada/repo/app-feature'], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:active', 'claude:sibling']) + }) + + // Why: before worktree.ps resolves, scopeFilterPaths is empty on a scoped tab; + // the builder must fall back to unnarrowed rather than filter to an empty list. + it('falls back to unnarrowed when a scoped tab has no scopeFilterPaths yet', () => { + const sessions = [ + session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'workspace', + scopeFilterPaths: [], + activeWorktreePath: null, + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:a', 'claude:b']) + }) + + it('shows all sessions under All scope regardless of path', () => { + const sessions = [ + session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'all', + scopeFilterPaths: [], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:a', 'claude:b']) + }) +}) + +describe('MOBILE_AI_VAULT_CAPABILITY', () => { + it('stays in lockstep with the host AI_VAULT_RUNTIME_CAPABILITY string', () => { + // Why: mobile's protocol-version copy lacks the host constant; this literal + // must match src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY. + expect(MOBILE_AI_VAULT_CAPABILITY).toBe('aiVault.v1') + }) +}) + +describe('shouldShowMobileCurrentWorktreeBadge', () => { + it('hides the repeated current-worktree badge in Workspace scope', () => { + expect(shouldShowMobileCurrentWorktreeBadge('workspace')).toBe(false) + }) + + it('keeps the badge in mixed Project and All scopes', () => { + expect(shouldShowMobileCurrentWorktreeBadge('project')).toBe(true) + expect(shouldShowMobileCurrentWorktreeBadge('all')).toBe(true) + }) +}) diff --git a/mobile/src/agent-history/agent-history-session-card.ts b/mobile/src/agent-history/agent-history-session-card.ts new file mode 100644 index 00000000000..e18ac3f5410 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-card.ts @@ -0,0 +1,68 @@ +import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import { latestSessionConversationTurn } from '../../../src/shared/ai-vault-session-display' +import { aiVaultAgentLabel } from '../../../src/shared/ai-vault-types' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { formatTimeAgo } from '../worktree/agent-row-display' + +export type MobileAgentHistoryCard = { + id: string + agent: AiVaultSession['agent'] + agentLabel: string + title: string + lastMessage: string + messageCount: number + timeAgo: string + isCurrentWorktree: boolean +} + +export type MobileAgentHistoryResumeActionState = { + disabled: boolean + loading: boolean +} + +// Why: a session belongs to the active worktree when its recorded cwd is inside +// (or equal to) the worktree path; session rows carry no host id, so this is a +// path-prefix check (same logic the desktop badge uses). +export function isSessionInActiveWorktree( + session: Pick, + activeWorktreePath: string | null +): boolean { + if (!activeWorktreePath || !session.cwd) { + return false + } + return isPathInsideOrEqual(activeWorktreePath, session.cwd) +} + +export function buildMobileAgentHistoryCard( + session: AiVaultSession, + activeWorktreePath: string | null, + now: number +): MobileAgentHistoryCard { + const latestTurn = latestSessionConversationTurn(session) + const updatedAtMs = Date.parse(session.updatedAt ?? session.modifiedAt) + return { + id: session.id, + agent: session.agent, + agentLabel: aiVaultAgentLabel(session.agent), + title: session.title || 'Untitled session', + lastMessage: latestTurn?.text.trim() ?? '', + messageCount: session.messageCount, + timeAgo: Number.isFinite(updatedAtMs) ? formatTimeAgo(updatedAtMs, now) : '', + isCurrentWorktree: isSessionInActiveWorktree(session, activeWorktreePath) + } +} + +export function buildMobileAgentHistoryResumeActionState( + sessions: readonly Pick[], + resumingSessionId: string | null +): ReadonlyMap { + return new Map( + sessions.map((session) => [ + session.id, + { + disabled: resumingSessionId !== null, + loading: resumingSessionId === session.id + } + ]) + ) +} diff --git a/mobile/src/agent-history/agent-history-session-worktree.test.ts b/mobile/src/agent-history/agent-history-session-worktree.test.ts new file mode 100644 index 00000000000..9a7ad0d3981 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-worktree.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { + canResumeInMobileSessionWorktree, + resolveMobileAgentHistorySessionWorktree +} from './agent-history-session-worktree' + +function session(cwd: string | null): Pick { + return { cwd } +} + +function worktree(overrides: Partial & { worktreeId: string; path: string }): Worktree { + return { + repoId: 'repo-1', + repo: 'orca', + branch: 'main', + displayName: overrides.worktreeId, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +describe('resolveMobileAgentHistorySessionWorktree', () => { + it('matches the active worktree when session cwd is inside it', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app/src'), + worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })], + activeWorktreeId: 'wt-1' + }) + expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wt-1' }) + }) + + it('prefers the longest active path match', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app/packages/mobile'), + worktrees: [ + worktree({ worktreeId: 'root', path: '/Users/ada/repo' }), + worktree({ worktreeId: 'app', path: '/Users/ada/repo/app' }) + ], + activeWorktreeId: 'root' + }) + expect(resolved).toMatchObject({ status: 'active', worktreeId: 'app' }) + }) + + it('marks archived worktrees as unavailable for resume', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app'), + worktrees: [ + worktree({ worktreeId: 'archived', path: '/Users/ada/repo/app', isArchived: true }) + ], + activeWorktreeId: 'other' + }) + expect(resolved).toMatchObject({ status: 'archived', worktreeId: 'archived' }) + expect(canResumeInMobileSessionWorktree(resolved)).toBe(false) + }) + + it('returns null when the session cwd has no active worktree match', () => { + expect( + resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/missing'), + worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })], + activeWorktreeId: 'wt-1' + }) + ).toBeNull() + }) + + it('matches WSL UNC worktree paths against Linux transcript paths', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/home/ada/repo/app'), + worktrees: [ + worktree({ + worktreeId: 'wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + }) + ], + activeWorktreeId: 'wsl' + }) + expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wsl' }) + }) +}) diff --git a/mobile/src/agent-history/agent-history-session-worktree.ts b/mobile/src/agent-history/agent-history-session-worktree.ts new file mode 100644 index 00000000000..0e859bbf5ee --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-worktree.ts @@ -0,0 +1,103 @@ +import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' + +export type MobileAgentHistorySessionWorktreeStatus = 'current' | 'active' | 'archived' + +export type MobileAgentHistorySessionWorktreeInfo = { + status: MobileAgentHistorySessionWorktreeStatus + worktreeId: string + path: string +} + +type WorktreeCandidate = { + worktree: WorktreeWithPriorIds + path: string + source: 'current-path' | 'prior-path' +} + +type WorktreeWithPriorIds = Worktree & { + priorWorktreeIds?: readonly string[] +} + +export function resolveMobileAgentHistorySessionWorktree(args: { + session: Pick + worktrees: readonly Worktree[] + activeWorktreeId: string | null +}): MobileAgentHistorySessionWorktreeInfo | null { + if (!args.session.cwd) { + return null + } + const sessionCwd = args.session.cwd + + const candidates = buildMobileWorktreeCandidates(args.worktrees) + .filter((candidate) => isSessionInWorktreePath(candidate.path, sessionCwd)) + .sort(compareWorktreeCandidates) + const best = candidates[0] + if (!best) { + return null + } + + return { + status: + best.worktree.worktreeId === args.activeWorktreeId + ? 'current' + : best.worktree.isArchived + ? 'archived' + : 'active', + worktreeId: best.worktree.worktreeId, + path: best.path + } +} + +export function canResumeInMobileSessionWorktree( + worktreeInfo: MobileAgentHistorySessionWorktreeInfo | null +): boolean { + return Boolean(worktreeInfo && worktreeInfo.status !== 'archived') +} + +function buildMobileWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { + const candidates: WorktreeCandidate[] = [] + for (const worktree of worktrees as readonly WorktreeWithPriorIds[]) { + if (hasUsablePath(worktree.path)) { + candidates.push({ worktree, path: worktree.path, source: 'current-path' }) + } + for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { + const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) + if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { + continue + } + candidates.push({ worktree, path: parsed.worktreePath, source: 'prior-path' }) + } + } + return candidates +} + +function hasUsablePath(pathValue: string): boolean { + return Boolean(pathValue.trim() && isRuntimePathAbsolute(pathValue)) +} + +function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean { + if (isPathInsideOrEqual(worktreePath, sessionCwd)) { + return true + } + const wslPath = parseWslUncPath(worktreePath) + return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false +} + +function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number { + const lengthDifference = normalizedPathLength(right.path) - normalizedPathLength(left.path) + if (lengthDifference !== 0) { + return lengthDifference + } + if (left.source === right.source) { + return 0 + } + return left.source === 'current-path' ? -1 : 1 +} + +function normalizedPathLength(pathValue: string): number { + return pathValue.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase().length +} diff --git a/mobile/src/agent-history/agent-history-styles.ts b/mobile/src/agent-history/agent-history-styles.ts new file mode 100644 index 00000000000..c3174623bf4 --- /dev/null +++ b/mobile/src/agent-history/agent-history-styles.ts @@ -0,0 +1,242 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + header: { + backgroundColor: colors.bgBase + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + backButton: { + padding: spacing.xs, + borderRadius: radii.button + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + titleBlock: { + flex: 1, + marginHorizontal: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '600' + }, + meta: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + refreshButton: { + padding: spacing.xs, + borderRadius: radii.button + }, + refreshButtonPressed: { + backgroundColor: colors.bgRaised + }, + scopeTabs: { + flexDirection: 'row', + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + gap: spacing.xs + }, + scopeTab: { + flex: 1, + alignItems: 'center', + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgPanel + }, + scopeTabActive: { + backgroundColor: colors.bgRaised + }, + scopeTabText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + scopeTabTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + searchRow: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm + }, + searchInput: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + list: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.xl + }, + groupHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + gap: spacing.sm + }, + groupHeaderText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600', + textTransform: 'uppercase' + }, + groupHeaderCount: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + padding: spacing.md, + marginBottom: spacing.sm + }, + cardPressed: { + backgroundColor: colors.bgRaised + }, + cardTopRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + cardTitle: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + cardTimeAgo: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + cardLastMessage: { + color: colors.textSecondary, + fontSize: typography.metaSize, + marginTop: spacing.xs + }, + cardMetaRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.xs + }, + cardMetaText: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + currentBadge: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + currentBadgeText: { + color: colors.accentBlue, + fontSize: typography.metaSize, + fontWeight: '600' + }, + resumeButton: { + minHeight: 28, + minWidth: 28, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 'auto', + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + borderRadius: radii.button + }, + resumeButtonPressed: { + opacity: 0.78 + }, + resumeButtonDisabled: { + opacity: 0.45 + }, + preview: { + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + gap: spacing.sm + }, + previewTurn: { + gap: 2 + }, + previewRole: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600', + textTransform: 'uppercase' + }, + previewText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + noticeBanner: { + marginHorizontal: spacing.md, + marginTop: spacing.sm, + padding: spacing.sm, + borderRadius: radii.input, + backgroundColor: colors.bgPanel + }, + noticeText: { + color: colors.statusAmber, + fontSize: typography.metaSize + }, + resumeBanner: { + marginHorizontal: spacing.md, + marginTop: spacing.sm, + padding: spacing.sm, + borderRadius: radii.input, + backgroundColor: colors.bgPanel + }, + resumeBannerText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + state: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + gap: spacing.sm + }, + stateTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + stateText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + textAlign: 'center' + }, + retryButton: { + marginTop: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + retryText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts new file mode 100644 index 00000000000..a0a82bee019 --- /dev/null +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useHostClient, useForceReconnect } from '../transport/client-context' +import type { RpcSuccess } from '../transport/types' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultScope, + AiVaultSession +} from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' + +export { MOBILE_AI_VAULT_CAPABILITY } + +// Why: mirror the desktop renderer's SESSION_LIMIT=500. limit caps only the +// global recency list; the in-scope Claude union is capped separately host-side, +// so the response can still be large — hence virtualization + lazy preview. +const MOBILE_AI_VAULT_SESSION_LIMIT = 500 + +export type AgentHistoryScreenState = + | { kind: 'loading' } + | { kind: 'unsupported' } + | { kind: 'error'; message: string } + | { kind: 'ready'; sessions: AiVaultSession[]; issues: AiVaultScanIssue[] } + +type StatusWithCapabilities = { capabilities?: string[] } + +export type MobileAgentHistoryStateParams = { + hostId: string + worktreeId: string + worktrees: readonly Worktree[] + // Why: distinguishes "worktree list not fetched yet" from "fetched, but this + // worktree isn't in it" so a scoped tab holds loading only for the former. + worktreesLoaded: boolean +} + +export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams) { + const { hostId, worktreeId, worktrees, worktreesLoaded } = params + const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() + const [scope, setScope] = useState('workspace') + const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [hostStatusResult, setHostStatusResult] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const generationRef = useRef(0) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const activeWorktree = useMemo( + () => worktrees.find((worktree) => worktree.worktreeId === worktreeId) ?? null, + [worktrees, worktreeId] + ) + const activeWorktreePath = activeWorktree?.path ?? null + // Why: the host union only widens, so the screen narrows by these cwd + // path-prefixes for the current scope (empty for 'all'). Same derivation the + // RPC scopePaths use, reused client-side for the actual narrowing. + const scopeFilterPaths = useMemo( + () => deriveMobileAiVaultScopePaths(scope, activeWorktree, worktrees), + [scope, activeWorktree, worktrees] + ) + + const loadSessions = useCallback( + async (options: { scope: AiVaultScope; force: boolean }): Promise => { + const generation = generationRef.current + 1 + generationRef.current = generation + const isCurrent = () => mountedRef.current && generationRef.current === generation + + if (!client || connState !== 'connected') { + if (isCurrent()) { + // Why: keep the stale list visible through transient reconnects + // (connState flips re-run the load effect) instead of tearing it + // down to a full-screen error, matching the host list screen. + setScreenState((prev) => + prev.kind === 'ready' ? prev : { kind: 'error', message: 'Waiting for host…' } + ) + } + return + } + + setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + try { + // Gate on the capability so older hosts lacking the method are detected + // and we never call a missing RPC. + const statusResponse = await client.sendRequest('status.get') + if (!isCurrent()) { + return + } + if (!statusResponse.ok) { + throw new Error(statusResponse.error?.message || 'Unable to reach host') + } + const status = (statusResponse as RpcSuccess).result as StatusWithCapabilities + setHostStatusResult(status) + if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) { + setScreenState({ kind: 'unsupported' }) + return + } + + // Why: a scoped tab needs the active worktree's path to narrow. Until the + // worktree list has loaded, hold loading rather than firing an unscoped + // fetch that would briefly show unrelated host history. Once loaded we + // proceed even if the worktree isn't found, to avoid a stuck spinner. + if (options.scope !== 'all' && !activeWorktree && !worktreesLoaded) { + if (isCurrent()) { + setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + } + return + } + + const scopePaths = deriveMobileAiVaultScopePaths(options.scope, activeWorktree, worktrees) + const response = await client.sendRequest('aiVault.listSessions', { + limit: MOBILE_AI_VAULT_SESSION_LIMIT, + force: options.force, + scopePaths + }) + if (!isCurrent()) { + return + } + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load agent sessions') + } + const result = (response as RpcSuccess).result as AiVaultListResult + setScreenState({ kind: 'ready', sessions: result.sessions, issues: result.issues }) + } catch (err) { + if (!isCurrent()) { + return + } + const message = err instanceof Error ? err.message : 'Unable to load agent sessions' + setHostStatusResult(null) + setScreenState({ kind: 'error', message }) + } + }, + [activeWorktree, client, connState, worktrees, worktreesLoaded] + ) + + // Initial + reconnect load. Why: scope switches reuse the host's 15s cache + // (force:false) so a tab tap is cheap; only an explicit refresh bypasses it. + useEffect(() => { + void loadSessions({ scope, force: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- scope handled by onSelectScope + }, [loadSessions]) + + const onSelectScope = useCallback( + (nextScope: AiVaultScope) => { + setScope(nextScope) + void loadSessions({ scope: nextScope, force: false }) + }, + [loadSessions] + ) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + try { + // Why: pull-to-refresh bypasses the host TTL (force:true) and joins any + // active inflight scan; otherwise a refresh within 15s shows no change. + await loadSessions({ scope, force: true }) + } finally { + if (mountedRef.current) { + setRefreshing(false) + } + } + }, [loadSessions, scope]) + + const retry = useCallback(() => { + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadSessions({ scope, force: false }) + }, [connState, forceReconnect, hostId, loadSessions, scope]) + + return { + connState, + scope, + screenState, + refreshing, + hostStatusResult, + activeWorktreePath, + scopeFilterPaths, + onSelectScope, + onRefresh, + retry + } +} + +export type MobileAgentHistoryState = ReturnType diff --git a/mobile/src/agent-history/worktree-navigation-actions.ts b/mobile/src/agent-history/worktree-navigation-actions.ts new file mode 100644 index 00000000000..8359c8ee020 --- /dev/null +++ b/mobile/src/agent-history/worktree-navigation-actions.ts @@ -0,0 +1,50 @@ +import { GitBranch } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { colors } from '../theme/mobile-theme' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' +import { MobileAgentSessionHistoryIcon } from './MobileAgentSessionHistoryIcon' + +type Args = { + hostId: string + worktreeId: string + worktreeName: string + hostCapabilities: readonly string[] + navigate: (target: string) => void + onDone: () => void +} + +// Why: builds the per-worktree "navigate to a screen" action-sheet actions +// (Source Control + Agent Session History). The Agent Session History action is +// included only on hosts advertising the aiVault.v1 capability, so we never +// navigate to a screen that would call a missing RPC method. Extracted from the +// host index action sheet to keep that file under its max-lines budget. +export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] { + const actions: ActionSheetAction[] = [ + { + label: 'Source Control', + icon: GitBranch, + onPress: () => { + const params = new URLSearchParams({ name: args.worktreeName, origin: 'host' }) + args.navigate( + `/h/${args.hostId}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}` + ) + args.onDone() + } + } + ] + if (args.hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) { + actions.push({ + label: 'Agent Session History', + renderIcon: () => + MobileAgentSessionHistoryIcon({ size: 16, color: colors.textSecondary, strokeWidth: 2 }), + onPress: () => { + const params = new URLSearchParams({ name: args.worktreeName }) + args.navigate( + `/h/${args.hostId}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}` + ) + args.onDone() + } + }) + } + return actions +} diff --git a/mobile/src/session/MobileSessionHeaderIconButton.tsx b/mobile/src/session/MobileSessionHeaderIconButton.tsx new file mode 100644 index 00000000000..68e1a1173a4 --- /dev/null +++ b/mobile/src/session/MobileSessionHeaderIconButton.tsx @@ -0,0 +1,39 @@ +import type { ComponentType } from 'react' +import { Pressable } from 'react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from '../../app/h/[hostId]/session/mobile-session-styles' + +type HeaderIconProps = { + size?: number + color?: string + strokeWidth?: number +} + +type MobileSessionHeaderIconButtonProps = { + active?: boolean + accessibilityLabel: string + icon: ComponentType + onPress: () => void +} + +export function MobileSessionHeaderIconButton({ + active = false, + accessibilityLabel, + icon: Icon, + onPress +}: MobileSessionHeaderIconButtonProps) { + return ( + [ + styles.filesButton, + pressed && styles.filesButtonPressed, + active && styles.filesButtonActive + ]} + onPress={onPress} + hitSlop={8} + accessibilityLabel={accessibilityLabel} + > + + + ) +} diff --git a/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx new file mode 100644 index 00000000000..d6f6ebb4518 --- /dev/null +++ b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx @@ -0,0 +1,57 @@ +import { ListChecks } from 'lucide-react-native' +import { MobileAgentSessionHistoryIcon } from '../agent-history/MobileAgentSessionHistoryIcon' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { colors } from '../theme/mobile-theme' + +type Props = { + visible: boolean + showAgentSessionHistory: boolean + showChecks: boolean + onOpenAgentSessionHistory: () => void + onOpenChecks: () => void + onClose: () => void +} + +export function MobileSessionHeaderMoreActionsSheet({ + visible, + showAgentSessionHistory, + showChecks, + onOpenAgentSessionHistory, + onOpenChecks, + onClose +}: Props) { + return ( + ( + + ), + onPress: onOpenAgentSessionHistory + } + ] + : []), + ...(showChecks + ? [ + { + label: 'Checks', + hint: 'Open pull request checks', + icon: ListChecks, + onPress: onOpenChecks + } + ] + : []) + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts new file mode 100644 index 00000000000..c97849298fb --- /dev/null +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAiVaultResumeLaunch, + buildMobileAiVaultResumeCommand, + createMobileAiVaultResumeMutationRegistry, + readMobileRuntimeHostPlatform, + readMobileRuntimeTerminalWindowsShell, + resolveMobileAiVaultResumePlatform, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS +} from './ai-vault-resume-launch' + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session 1', + title: 'Resume me', + cwd: '/Users/ada/repo', + branch: 'main', + model: null, + filePath: '/Users/ada/.claude/session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-06-29T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('buildMobileAiVaultResumeCommand', () => { + it('delegates POSIX command construction to the shared AI Vault builder', () => { + expect(buildMobileAiVaultResumeCommand({ session: session(), hostPlatform: 'darwin' })).toBe( + "cd '/Users/ada/repo' && claude --resume 'session 1'" + ) + }) + + it('uses PowerShell-safe queued command construction for default local Windows terminals', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: 'C:\\repo app', + codexHome: 'C:\\Users\\Ada\\.codex' + }), + hostPlatform: 'win32' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\repo app'; $env:CODEX_HOME='C:\\Users\\Ada\\.codex'; codex resume 'codex-1'" + ) + }) + + it('resumes OMP sessions by absolute transcript path like desktop', () => { + // Regression: custom OMP_CODING_AGENT_DIR / WSL-store sessions miss on an + // id lookup, so the forwarded filePath must win over the session id. + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: '/Users/ada/.omp/agent/sessions/repo/sess.jsonl', + cwd: '/Users/ada/repo' + }), + hostPlatform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/sess.jsonl'") + }) + + it('uses cmd wrapping when the host Windows terminal is configured as cmd', () => { + const command = buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: 'C:\\repo app', + codexHome: 'C:\\Users\\Ada\\.codex' + }), + hostPlatform: 'win32', + hostTerminalWindowsShell: 'cmd.exe' + }) + expect(command).toContain('cmd /d /s /c') + expect(command).toContain('cd /d ""C:\\repo app""') + expect(command).toContain('set ""CODEX_HOME=C:\\Users\\Ada\\.codex""') + expect(command).toContain('codex resume ""codex-1""') + }) + + it('uses POSIX command construction and converts Codex home for WSL targets', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: '/home/ada/repo', + codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' + }), + hostPlatform: 'linux' + }) + ).toBe("cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'codex-1'") + }) + + it('passes command overrides through to the shared builder', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ sessionId: 'abc' }), + hostPlatform: 'linux', + commandOverride: 'claude-dev' + }) + ).toBe("cd '/Users/ada/repo' && claude-dev --resume 'abc'") + }) +}) + +describe('buildMobileAiVaultResumeLaunch', () => { + it('uses shared TUI startup planning for default args, env, and launch config', () => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ + agent: 'claude', + sessionId: 'abc 123', + cwd: '/Users/ada/repo' + }), + hostPlatform: 'darwin', + settings: { + agentCmdOverrides: { claude: 'claude-dev' }, + agentDefaultArgs: { claude: '--model opus' }, + agentDefaultEnv: { claude: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } } + } + }) + expect(launch.command).toBe( + "cd '/Users/ada/repo' && claude-dev '--model' 'opus' '--resume' 'abc 123'" + ) + expect(launch.env).toEqual({ ANTHROPIC_BASE_URL: 'http://localhost:3000' }) + expect(launch.launchAgent).toBe('claude') + expect(launch.launchConfig).toEqual({ + agentCommand: "claude-dev '--model' 'opus'", + agentArgs: '--model opus', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }) + }) +}) + +describe('resumeAiVaultSessionInTerminal', () => { + it('creates a fresh terminal and sends the command with Enter', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + command: 'claude --resume abc', + env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }, + launchAgent: 'claude', + clientMutationId: 'resume-1' + }) + ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) + expect(sendRequest).toHaveBeenNthCalledWith( + 1, + 'session.tabs.createTerminal', + { + worktree: 'id:worktree-1', + env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }, + launchAgent: 'claude', + clientMutationId: 'resume-1' + }, + // Why: a socket drop mid-resume must reject within the request timeout + // instead of parking on the reconnect waiter with the spinner pinned. + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(sendRequest).toHaveBeenNthCalledWith( + 2, + 'terminal.send', + { + terminal: 'pty-1', + text: 'claude --resume abc', + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + }) + + it('throws when terminal creation fails', async () => { + const sendRequest = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { message: 'no terminal' } + }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('no terminal') + }) + + it('throws when the created terminal response is malformed', async () => { + const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('Created terminal response was invalid') + }) + + it('throws when terminal send fails or is locked', async () => { + const failedSend = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } + }) + .mockResolvedValueOnce({ ok: false, error: { message: 'send failed' } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest: failedSend }, 'worktree-1', { + command: 'command' + }) + ).rejects.toThrow('send failed') + + const lockedSend = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: false } } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest: lockedSend }, 'worktree-1', { + command: 'command' + }) + ).rejects.toThrow('Terminal input is locked') + }) +}) + +describe('createMobileAiVaultResumeMutationRegistry', () => { + it('reuses the claimed id across retries until a success releases it', () => { + let mints = 0 + const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => { + mints += 1 + return `${sessionId}:mutation-${mints}` + }) + + expect(registry.claim('session-a')).toBe('session-a:mutation-1') + // A failed attempt keeps the key so the host can dedup the retry. + expect(registry.claim('session-a')).toBe('session-a:mutation-1') + + registry.releaseOnSuccess('session-a') + // A resume after success mints fresh so the user can fork intentionally. + expect(registry.claim('session-a')).toBe('session-a:mutation-2') + }) + + it('tracks sessions independently', () => { + const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => `${sessionId}:id`) + expect(registry.claim('session-a')).toBe('session-a:id') + expect(registry.claim('session-b')).toBe('session-b:id') + registry.releaseOnSuccess('session-b') + expect(registry.claim('session-a')).toBe('session-a:id') + }) +}) + +describe('resume platform helpers', () => { + it('reads a valid host platform from status.get', () => { + expect(readMobileRuntimeHostPlatform({ hostPlatform: 'win32' })).toBe('win32') + expect(readMobileRuntimeHostPlatform({ hostPlatform: 'not-a-platform' })).toBeNull() + }) + + it('reads the host Windows terminal shell from status.get', () => { + expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: 'wsl.exe' })).toBe( + 'wsl.exe' + ) + expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: '' })).toBeNull() + }) + + it('uses Linux/POSIX construction for SSH targets and host platform for local targets', () => { + expect(resolveMobileAiVaultResumePlatform('ssh', 'win32')).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('local', 'darwin')).toBe('darwin') + expect( + resolveMobileAiVaultResumePlatform( + 'local', + 'win32', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + ) + ).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('local', 'win32', 'C:\\repo', 'linux')).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull() + }) +}) diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts new file mode 100644 index 00000000000..61144c0de2b --- /dev/null +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -0,0 +1,269 @@ +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildAiVaultResumeCommand, + buildAiVaultResumeShellCommand +} from '../../../src/shared/ai-vault-types' +import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' +import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' +import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../src/shared/tui-agent-launch-defaults' +import type { TuiAgent } from '../../../src/shared/types' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' +import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' +import type { RpcClient } from '../transport/rpc-client' +import { + readMobileReviewCreatedTerminal, + readMobileReviewTerminalSendAccepted, + type MobileReviewTerminalTab +} from './mobile-diff-review-rpc' +import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' + +const NODE_PLATFORMS = new Set([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +]) + +export function buildMobileAiVaultResumeCommand(args: { + session: Pick & + Partial> + hostPlatform: NodeJS.Platform + hostTerminalWindowsShell?: string | null + commandOverride?: string | null +}): string { + // Why: this command is typed into the freshly created host terminal, so on + // Windows it must match the host's live shell instead of the phone platform. + const shell = + args.hostPlatform === 'win32' + ? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell) + : undefined + return buildAiVaultResumeCommand({ + agent: args.session.agent, + sessionId: args.session.sessionId, + // Why: OMP resumes by absolute transcript path (custom OMP dir / WSL-store + // sessions miss on an id lookup), so mobile forwards it like desktop does. + resumeFilePath: args.session.filePath, + cwd: args.session.cwd, + platform: args.hostPlatform, + commandOverride: args.commandOverride, + codexHome: getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform), + shell + }) +} + +export type MobileAiVaultResumeSettings = { + agentCmdOverrides?: Partial> + agentDefaultArgs?: Partial> + agentDefaultEnv?: Partial>> +} + +export type MobileAiVaultResumeLaunch = { + command: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent +} + +export function buildMobileAiVaultResumeLaunch(args: { + session: Pick & + Partial> + hostPlatform: NodeJS.Platform + hostTerminalWindowsShell?: string | null + settings?: MobileAiVaultResumeSettings | null +}): MobileAiVaultResumeLaunch { + const shell = + args.hostPlatform === 'win32' + ? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell) + : undefined + const codexHome = getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform) + const cmdOverrides = normalizeMobileAiVaultResumeCommandOverrides( + args.settings?.agentCmdOverrides + ) + const commandOverride = cmdOverrides[args.session.agent] ?? null + if (isResumableTuiAgent(args.session.agent)) { + const startupPlan = buildAgentResumeStartupPlan({ + agent: args.session.agent, + providerSession: { key: 'session_id', id: args.session.sessionId }, + cmdOverrides, + platform: args.hostPlatform, + shell, + agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv) + }) + if (startupPlan) { + return { + command: buildAiVaultResumeShellCommand({ + resumeCommand: startupPlan.launchCommand, + cwd: args.session.cwd, + platform: args.hostPlatform, + codexHome, + shell + }), + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: startupPlan.agent + } + } + } + return { + command: buildMobileAiVaultResumeCommand({ + session: args.session, + hostPlatform: args.hostPlatform, + hostTerminalWindowsShell: args.hostTerminalWindowsShell, + commandOverride + }) + } +} + +function normalizeMobileAiVaultResumeCommandOverrides( + overrides: Partial> | null | undefined +): Partial> { + const normalized: Partial> = {} + if (!overrides) { + return normalized + } + for (const [agent, command] of Object.entries(overrides) as [TuiAgent, string | null][]) { + if (typeof command === 'string' && command.trim()) { + normalized[agent] = command + } + } + return normalized +} + +// Why: without an explicit timeout, a socket drop mid-resume parks the request +// on the reconnect waiter for the full reconnect budget, pinning the spinner. +export const RESUME_RPC_TIMEOUT_MS = 30_000 + +export async function resumeAiVaultSessionInTerminal( + client: Pick, + worktreeId: string, + launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } +): Promise { + const created = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + ...(launch.env ? { env: launch.env } : {}), + ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), + ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), + ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}) + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!created.ok) { + throw new Error(created.error?.message || 'Failed to create terminal') + } + const terminalTab = readMobileReviewCreatedTerminal(created.result) + if (!terminalTab) { + throw new Error('Created terminal response was invalid') + } + const sent = await client.sendRequest( + 'terminal.send', + { + terminal: terminalTab.terminal, + text: launch.command, + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!sent.ok) { + throw new Error(sent.error?.message || 'Failed to send resume command') + } + if (!readMobileReviewTerminalSendAccepted(sent.result)) { + throw new Error('Terminal input is locked') + } + return terminalTab +} + +export type MobileAiVaultResumeMutationRegistry = { + claim(sessionId: string): string + releaseOnSuccess(sessionId: string): void +} + +// Why: a retry after a failed/interrupted resume must reuse the same +// idempotency key so the host dedups the create, while a resume after success +// mints a fresh key so the user can intentionally fork the session. +export function createMobileAiVaultResumeMutationRegistry( + mintId: (sessionId: string) => string +): MobileAiVaultResumeMutationRegistry { + const bySessionId = new Map() + return { + claim(sessionId: string): string { + const existing = bySessionId.get(sessionId) + if (existing) { + return existing + } + const minted = mintId(sessionId) + bySessionId.set(sessionId, minted) + return minted + }, + releaseOnSuccess(sessionId: string): void { + bySessionId.delete(sessionId) + } + } +} + +export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null { + if (!statusResult || typeof statusResult !== 'object') { + return null + } + const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform + return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform) + ? (hostPlatform as NodeJS.Platform) + : null +} + +export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null { + if (!statusResult || typeof statusResult !== 'object') { + return null + } + const shell = (statusResult as { terminalWindowsShell?: unknown }).terminalWindowsShell + return typeof shell === 'string' && shell.trim().length > 0 ? shell : null +} + +export function resolveMobileAiVaultResumePlatform( + targetStatus: MobileAiVaultResumeTargetStatus, + hostPlatform: NodeJS.Platform | null, + workspacePath?: string | null, + terminalPlatform?: NodeJS.Platform | null +): NodeJS.Platform | null { + if (targetStatus === 'ssh') { + // Why: desktop builds SSH resume commands for the remote POSIX execution + // host instead of the phone or local desktop platform. + return 'linux' + } + if (targetStatus === 'local') { + if (terminalPlatform === 'linux' && hostPlatform === 'win32') { + // Why: Windows-hosted WSL project terminals run a POSIX shell even when + // the visible workspace path is a normal Windows path. + return 'linux' + } + if (workspacePath && parseWslUncPath(workspacePath)) { + // Why: a WSL UNC workspace on a Windows host runs in a Linux shell. + return 'linux' + } + return hostPlatform + } + return null +} + +function getMobileAiVaultResumeCodexHome( + codexHome: string | null, + platform: NodeJS.Platform +): string | null { + if (!codexHome || platform !== 'linux') { + return codexHome + } + return parseWslUncPath(codexHome)?.linuxPath ?? codexHome +} diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index fb2845143d1..1b17fa4f1e1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -4,6 +4,7 @@ import { nextActivePanel, resolvePanelAction, panelRouteDescriptor, + shouldShowSessionHeaderChecksAction, type ActivePanel } from './session-panel-host' @@ -77,6 +78,48 @@ describe('canDockSessionPanel', () => { }) }) +describe('shouldShowSessionHeaderChecksAction', () => { + it('shows Checks on git worktree routes once a checks-capable provider is confirmed', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: true, + hostedChecksSupported: true + }) + ).toBe(true) + }) + + it('hides Checks for providers without hosted checks (pr-panel guard would no-op it)', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: true, + hostedChecksSupported: false + }) + ).toBe(false) + }) + + it('hides Checks until the provider probe resolves', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: false, + hostedChecksSupported: false + }) + ).toBe(false) + }) + + it('hides Checks on folder workspace session routes', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: true, + repoContextLoaded: true, + hostedChecksSupported: true + }) + ).toBe(false) + }) +}) + describe('panelRouteDescriptor', () => { it('maps each panel to its expo-router pathname', () => { expect(panelRouteDescriptor('sourceControl')).toEqual({ diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index fdf08602752..5e1e4793e25 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -19,6 +19,17 @@ export type PanelAction = export const SESSION_DOCK_MIN_MAIN_WIDTH = 360 +export function shouldShowSessionHeaderChecksAction(args: { + isFolderWorkspaceRoute: boolean + repoContextLoaded: boolean + hostedChecksSupported: boolean +}): boolean { + // Why: the hosted checks panel is provider-gated and the pr-panel guard + // force-closes it for unsupported providers, so offering the action there + // (or before the provider probe resolves) would be a silent no-op. + return !args.isFolderWorkspaceRoute && args.repoContextLoaded && args.hostedChecksSupported +} + export function canDockSessionPanel(args: { isWideLayout: boolean availableWidth: number diff --git a/mobile/src/worktree/mobile-workspace-lineage.test.ts b/mobile/src/worktree/mobile-workspace-lineage.test.ts index f32219da431..f844593e371 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.test.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.test.ts @@ -25,11 +25,18 @@ function worktree(overrides: Partial = {}): Worktree { describe('applyMobileWorkspaceLineage', () => { it('places visible children directly under their parent', () => { - const parent = worktree({ worktreeId: 'parent', displayName: 'parent' }) + const parent = worktree({ + worktreeId: 'parent', + displayName: 'parent', + worktreeInstanceId: 'parent-instance' + }) const child = worktree({ worktreeId: 'child', displayName: 'child', - parentWorktreeId: 'parent' + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' }) const rows = applyMobileWorkspaceLineage([child, parent]) @@ -61,6 +68,31 @@ describe('applyMobileWorkspaceLineage', () => { expect(rows[0]?.lineageCollapsed).toBe(true) }) + it('keeps legacy host lineage nesting when instance ids are unavailable', () => { + const parent = worktree({ worktreeId: 'parent' }) + const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' }) + + const rows = applyMobileWorkspaceLineage([child, parent]) + + expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child']) + }) + + it('does not nest stale lineage when instance ids no longer match', () => { + const parent = worktree({ worktreeId: 'parent', worktreeInstanceId: 'new-parent-instance' }) + const child = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'old-parent-instance' + }) + + const rows = applyMobileWorkspaceLineage([child, parent]) + + expect(rows.map((row) => row.worktreeId)).toEqual(['child', 'parent']) + expect(rows.map((row) => row.lineageDepth)).toEqual([0, 0]) + }) + it('keeps rows visible when lineage is cyclic', () => { const first = worktree({ worktreeId: 'first', parentWorktreeId: 'second' }) const second = worktree({ worktreeId: 'second', parentWorktreeId: 'first' }) diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index b28b7395928..bdf1ae2d8a1 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -4,17 +4,40 @@ export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string { return `workspace-lineage:${encodeURIComponent(worktreeId)}` } +function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean { + if ( + worktree.lineageWorktreeInstanceId === undefined && + worktree.parentWorktreeInstanceId === undefined + ) { + return true + } + // Why: desktop rejects stale lineage when a path is reused by a new workspace + // instance; mobile needs the same guard before nesting parent/child rows. + return ( + worktree.worktreeInstanceId === worktree.lineageWorktreeInstanceId && + parent.worktreeInstanceId === worktree.parentWorktreeInstanceId + ) +} + export function applyMobileWorkspaceLineage( worktrees: readonly Worktree[], collapsedGroups: ReadonlySet = new Set() ): Worktree[] { const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId)) + const worktreeById = new Map(worktrees.map((worktree) => [worktree.worktreeId, worktree])) const childrenByParentId = new Map() const childIds = new Set() for (const worktree of worktrees) { const parentId = worktree.parentWorktreeId - if (!parentId || parentId === worktree.worktreeId || !visibleIds.has(parentId)) { + const parent = parentId ? worktreeById.get(parentId) : undefined + if ( + !parentId || + parentId === worktree.worktreeId || + !visibleIds.has(parentId) || + !parent || + !hasValidLineageParent(worktree, parent) + ) { continue } childIds.add(worktree.worktreeId) diff --git a/mobile/src/worktree/workspace-list-ordering.ts b/mobile/src/worktree/workspace-list-ordering.ts new file mode 100644 index 00000000000..4654c6091e5 --- /dev/null +++ b/mobile/src/worktree/workspace-list-ordering.ts @@ -0,0 +1,124 @@ +import type { Worktree } from './workspace-list-types' +import type { MobileSortMode } from './workspace-view-settings' + +export const CREATE_GRACE_MS = 5 * 60 * 1000 + +function getManualSortRank(worktree: Worktree): number | null { + const rank = worktree.manualOrder ?? worktree.sortOrder + return typeof rank === 'number' && Number.isFinite(rank) ? rank : null +} + +function compareDisplayName(a: Worktree, b: Worktree): number { + return a.displayName.localeCompare(b.displayName) +} + +function getRecentActivity(worktree: Worktree): number { + const lastActivityAt = worktree.lastActivityAt ?? 0 + const lastOutputAt = worktree.lastOutputAt ?? 0 + // Why: headless serve hosts only stamp lastActivityAt at creation, so live + // terminal output must still count as recency for mobile-only pairings. + return Math.max( + Number.isFinite(lastActivityAt) ? lastActivityAt : 0, + Number.isFinite(lastOutputAt) ? lastOutputAt : 0 + ) +} + +function effectiveRecentActivity(worktree: Worktree, now: number): number { + const lastActivityAt = getRecentActivity(worktree) + const { createdAt } = worktree + if ( + createdAt === undefined || + !Number.isFinite(createdAt) || + now >= createdAt + CREATE_GRACE_MS + ) { + return lastActivityAt + } + return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) +} + +function compareByRecent(a: Worktree, b: Worktree, now: number): number { + return ( + effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || compareDisplayName(a, b) + ) +} + +const AGENT_ATTENTION_STATUS_ORDER = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 } + +function compareByAgentAttention(a: Worktree, b: Worktree, now: number): number { + return ( + AGENT_ATTENTION_STATUS_ORDER[getWorktreeStatus(a)] - + AGENT_ATTENTION_STATUS_ORDER[getWorktreeStatus(b)] || compareByRecent(a, b, now) + ) +} + +export function sortWorktrees( + worktrees: Worktree[], + mode: MobileSortMode, + now = Date.now() +): Worktree[] { + if (mode === 'manual') { + return [...worktrees].sort((a, b) => { + const aRank = getManualSortRank(a) + const bRank = getManualSortRank(b) + if (aRank !== null && bRank !== null && aRank !== bRank) { + // Why: desktop assigns higher sort/manual ranks to earlier list positions. + return bRank - aRank + } + if (aRank !== null && bRank === null) { + return -1 + } + if (aRank === null && bRank !== null) { + return 1 + } + return compareDisplayName(a, b) + }) + } + return [...worktrees].sort((a, b) => { + if (mode === 'name') { + return compareDisplayName(a, b) + } + if (mode === 'recent') { + return compareByRecent(a, b, now) + } + if (mode === 'repo') { + const repoComparison = a.repo.localeCompare(b.repo) + return repoComparison || compareDisplayName(a, b) + } + const aRank = typeof a.sortOrder === 'number' && Number.isFinite(a.sortOrder) ? a.sortOrder : 0 + const bRank = typeof b.sortOrder === 'number' && Number.isFinite(b.sortOrder) ? b.sortOrder : 0 + if (aRank !== bRank) { + // Why: desktop persists its computed Agent activity order into sortOrder; + // mobile should render that source-of-truth before local fallback signals. + return bRank - aRank + } + if (aRank === 0) { + // Why: headless serve hosts never persist desktop smart ranks; unranked + // rows fall back to agent attention order instead of a frozen A-Z list. + return compareByAgentAttention(a, b, now) + } + return compareDisplayName(a, b) + }) +} + +export function getWorktreeStatus( + w: Worktree +): 'working' | 'active' | 'permission' | 'done' | 'inactive' { + // Why: desktop's sidebar activity is the parity source. Runtime status may + // still report retained/background PTYs as active after desktop hides them. + if (w.hasHostSidebarActivity === false) { + return 'inactive' + } + if (w.status && w.status !== 'inactive') { + return w.status + } + if (w.hasHostSidebarActivity === true) { + return 'active' + } + if (w.status) { + return w.status + } + if (w.liveTerminalCount > 0) { + return 'active' + } + return 'inactive' +} diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index e6de6d8f466..d6d04d43d3e 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -1,7 +1,13 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Worktree } from './workspace-list-sections' -import { buildSections, filterWorktrees, getWorktreeStatus } from './workspace-list-sections' +import { + CREATE_GRACE_MS, + buildSections, + filterWorktrees, + getWorktreeStatus, + sortWorktrees +} from './workspace-list-sections' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' function worktree(overrides: Partial = {}): Worktree { @@ -145,6 +151,16 @@ describe('getWorktreeStatus', () => { }) describe('buildSections', () => { + it('matches desktop Name sort by display name', () => { + const beta = worktree({ worktreeId: 'beta', displayName: 'Beta', repo: 'aaa' }) + const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', repo: 'zzz' }) + + expect(sortWorktrees([beta, alpha], 'name').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'beta' + ]) + }) + it('uses desktop manual order ranks in Manual sort mode', () => { const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 }) const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 }) @@ -168,6 +184,264 @@ describe('buildSections', () => { ]) }) + it('uses desktop display-name tie-breaks in Manual sort mode', () => { + const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', manualOrder: 10 }) + const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', manualOrder: 10 }) + + expect(sortWorktrees([zed, alpha], 'manual').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'zed' + ]) + }) + + it('uses desktop-persisted smart order before mobile fallback signals', () => { + const desktopFirst = worktree({ + worktreeId: 'desktop-first', + displayName: 'desktop-first', + sortOrder: 30, + status: 'inactive', + unread: false, + lastOutputAt: 1 + }) + const mobileFallbackFirst = worktree({ + worktreeId: 'mobile-fallback-first', + displayName: 'mobile-fallback-first', + sortOrder: 10, + status: 'working', + unread: true, + lastOutputAt: 100 + }) + + const sections = buildSections( + [mobileFallbackFirst, desktopFirst], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'workspaceStatus', + new Set(), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections.flatMap((section) => section.data.map((item) => item.worktreeId))).toEqual([ + 'desktop-first', + 'mobile-fallback-first' + ]) + }) + + it('uses desktop display-name tie-breaks for equal persisted smart ranks', () => { + const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', sortOrder: 20, unread: true }) + const alpha = worktree({ + worktreeId: 'alpha', + displayName: 'Alpha', + sortOrder: 20, + status: 'working', + lastOutputAt: 100 + }) + + expect(sortWorktrees([zed, alpha], 'smart').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'zed' + ]) + }) + + it('counts live terminal output as Recent activity for headless serve hosts', () => { + // Serve hosts only stamp lastActivityAt at creation, so output must rank. + const streamingOnServe = worktree({ + worktreeId: 'streaming', + displayName: 'streaming', + lastActivityAt: 100, + lastOutputAt: 1_000 + }) + const touchedOnDesktop = worktree({ + worktreeId: 'touched', + displayName: 'touched', + lastActivityAt: 200, + lastOutputAt: 1 + }) + + expect( + sortWorktrees([touchedOnDesktop, streamingOnServe], 'recent').map((item) => item.worktreeId) + ).toEqual(['streaming', 'touched']) + }) + + it('keeps desktop activity ranking in Recent when it is newest', () => { + const newerActivity = worktree({ + worktreeId: 'newer-activity', + displayName: 'newer', + lastActivityAt: 2_000, + lastOutputAt: 1 + }) + const quietOlder = worktree({ + worktreeId: 'quiet-older', + displayName: 'quiet', + lastActivityAt: 100, + lastOutputAt: 150 + }) + + expect( + sortWorktrees([quietOlder, newerActivity], 'recent').map((item) => item.worktreeId) + ).toEqual(['newer-activity', 'quiet-older']) + }) + + it('falls back to agent attention order in Smart when no desktop ranks exist', () => { + // Display names are deliberately reverse-alphabetical to prove status ranks. + const idle = worktree({ worktreeId: 'idle', displayName: 'Aardvark' }) + const needsPermission = worktree({ + worktreeId: 'needs-permission', + displayName: 'Zebra', + status: 'permission' + }) + const working = worktree({ worktreeId: 'working', displayName: 'Yak', status: 'working' }) + + expect( + sortWorktrees([idle, working, needsPermission], 'smart').map((item) => item.worktreeId) + ).toEqual(['needs-permission', 'working', 'idle']) + }) + + it('keeps desktop-ranked rows above unranked attention-fallback rows in Smart', () => { + const ranked = worktree({ worktreeId: 'ranked', displayName: 'Ranked', sortOrder: 5 }) + const unrankedWorking = worktree({ + worktreeId: 'unranked-working', + displayName: 'Working', + status: 'working' + }) + + expect( + sortWorktrees([unrankedWorking, ranked], 'smart').map((item) => item.worktreeId) + ).toEqual(['ranked', 'unranked-working']) + }) + + it('matches desktop Recent create grace for newly-created workspaces', () => { + const now = 10_000 + const created = worktree({ + worktreeId: 'created', + displayName: 'created', + lastActivityAt: 100, + createdAt: now - 1_000 + }) + const active = worktree({ + worktreeId: 'active', + displayName: 'active', + lastActivityAt: now + CREATE_GRACE_MS - 2_000 + }) + + expect(sortWorktrees([active, created], 'recent', now).map((item) => item.worktreeId)).toEqual([ + 'created', + 'active' + ]) + }) + + it('matches desktop Repo sort by repo display name then workspace display name', () => { + const betaAlpha = worktree({ + worktreeId: 'beta-alpha', + repo: 'Beta Repo', + displayName: 'Alpha' + }) + const alphaZed = worktree({ + worktreeId: 'alpha-zed', + repo: 'Alpha Repo', + displayName: 'Zed' + }) + const betaBravo = worktree({ + worktreeId: 'beta-bravo', + repo: 'Beta Repo', + displayName: 'Bravo' + }) + + expect( + sortWorktrees([betaBravo, betaAlpha, alphaZed], 'repo').map((item) => item.worktreeId) + ).toEqual(['alpha-zed', 'beta-alpha', 'beta-bravo']) + }) + + it('uses desktop localeCompare semantics for Repo sort names', () => { + const accentRepo = worktree({ + worktreeId: 'accent-repo', + repo: 'áb repo', + displayName: 'Alpha' + }) + const plainRepo = worktree({ + worktreeId: 'plain-repo', + repo: 'ab repo', + displayName: 'Zed' + }) + + expect(sortWorktrees([accentRepo, plainRepo], 'repo').map((item) => item.worktreeId)).toEqual([ + 'plain-repo', + 'accent-repo' + ]) + }) + + it('keeps a desktop-ranked parent and child stack above unrelated active rows', () => { + const parent = worktree({ + worktreeId: 'parent', + displayName: 'Add agents to mobile', + repo: 'orca', + sortOrder: 30, + status: 'inactive' + }) + const child = worktree({ + worktreeId: 'child', + displayName: 'Agent Session History resume (PR2)', + repo: 'orca', + parentWorktreeId: 'parent', + sortOrder: 20, + status: 'inactive' + }) + const unrelatedActive = worktree({ + worktreeId: 'active', + displayName: 'Overlapping tui output', + repo: 'orca', + sortOrder: 10, + status: 'working', + unread: true, + lastOutputAt: 100 + }) + + const sections = buildSections( + [unrelatedActive, child, parent], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + new Map([['orca', 'repo-1']]), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['parent', 'child', 'active']) + expect(sections[0]?.data.map((item) => item.lineageDepth)).toEqual([0, 1, 0]) + }) + + it('keeps the main workspace first inside repo-grouped sections like desktop', () => { + const child = worktree({ + worktreeId: 'child', + displayName: 'Child', + repo: 'orca', + sortOrder: 30, + isMainWorktree: false + }) + const main = worktree({ + worktreeId: 'main', + displayName: 'Main', + repo: 'orca', + sortOrder: 10, + isMainWorktree: true + }) + + const sections = buildSections( + [child, main], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + new Map([['orca', 'repo-1']]) + ) + + expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['main', 'child']) + }) + it('renders empty repo sections from repo placeholders in repo grouping', () => { const sections = buildSections( [worktree({ repoId: 'repo-1', repo: 'orca' })], @@ -307,7 +581,7 @@ describe('buildSections', () => { expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress']) }) - it('does not duplicate pinned worktrees in their canonical status group', () => { + it('keeps pinned worktrees in their canonical status group like desktop', () => { const pinned = worktree({ worktreeId: 'pinned', workspaceStatus: 'in-progress', @@ -326,7 +600,40 @@ describe('buildSections', () => { ) expect(withoutSectionListKeys(sections)).toEqual([ - { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] } + { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }, + { key: 'workspace-status:in-progress', title: 'In progress', data: [pinned] } + ]) + }) + + it('renders one sorted All section when grouping is off like desktop', () => { + const inactiveFirst = worktree({ + worktreeId: 'inactive-first', + displayName: 'inactive-first', + manualOrder: 30, + status: 'inactive' + }) + const activeSecond = worktree({ + worktreeId: 'active-second', + displayName: 'active-second', + manualOrder: 10, + status: 'working' + }) + + const sections = buildSections( + [activeSecond, inactiveFirst], + 'manual', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'none', + new Set(), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections.map((section) => section.key)).toEqual(['all']) + expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([ + 'inactive-first', + 'active-second' ]) }) diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 1691659e9f4..bdb9e4783e3 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -9,8 +9,10 @@ import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage' import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups' import type { FilterState, Section, Worktree } from './workspace-list-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' +import { sortWorktrees } from './workspace-list-ordering' export type { FilterState, Section, Worktree } from './workspace-list-types' +export { CREATE_GRACE_MS, getWorktreeStatus, sortWorktrees } from './workspace-list-ordering' function makeSection( key: string, @@ -31,29 +33,6 @@ function makeSection( } } -export function getWorktreeStatus( - w: Worktree -): 'working' | 'active' | 'permission' | 'done' | 'inactive' { - // Why: desktop's sidebar activity is the parity source. Runtime status may - // still report retained/background PTYs as active after desktop hides them. - if (w.hasHostSidebarActivity === false) { - return 'inactive' - } - if (w.status && w.status !== 'inactive') { - return w.status - } - if (w.hasHostSidebarActivity === true) { - return 'active' - } - if (w.status) { - return w.status - } - if (w.liveTerminalCount > 0) { - return 'active' - } - return 'inactive' -} - // Why: the previous 10-minute lastOutputAt window was too strict — most // worktrees with idle terminal prompts had no recent output and were excluded. // Any worktree with live terminals or unread output counts as "active". @@ -86,54 +65,12 @@ function isDefaultBranchWorkspace(w: Worktree): boolean { return branch === 'main' || branch === 'master' } -function getManualSortRank(worktree: Worktree): number | null { - const rank = worktree.manualOrder ?? worktree.sortOrder - return typeof rank === 'number' && Number.isFinite(rank) ? rank : null -} - -export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Worktree[] { - if (mode === 'manual') { - return [...worktrees].sort((a, b) => { - const aRank = getManualSortRank(a) - const bRank = getManualSortRank(b) - if (aRank !== null && bRank !== null && aRank !== bRank) { - // Why: desktop assigns higher sort/manual ranks to earlier list positions. - return bRank - aRank - } - if (aRank !== null && bRank === null) { - return -1 - } - if (aRank === null && bRank !== null) { - return 1 - } - return 0 - }) +function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { + const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) + if (mainWorktrees.length === 0) { + return worktrees } - return [...worktrees].sort((a, b) => { - if (mode === 'name') { - return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - } - if (mode === 'recent') { - return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) - } - if (mode === 'repo') { - const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' }) - return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - } - if (a.unread !== b.unread) { - return a.unread ? -1 : 1 - } - const aStatus = getWorktreeStatus(a) - const bStatus = getWorktreeStatus(b) - const statusOrder = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 } - if (statusOrder[aStatus] !== statusOrder[bStatus]) { - return statusOrder[aStatus] - statusOrder[bStatus] - } - if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0)) { - return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) - } - return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - }) + return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)] } export function filterWorktrees( @@ -155,7 +92,7 @@ export function filterWorktrees( const q = search.toLowerCase() result = result.filter( (w) => - (w.displayName || w.repo).toLowerCase().includes(q) || + w.displayName.toLowerCase().includes(q) || w.branch.toLowerCase().includes(q) || w.repo.toLowerCase().includes(q) ) @@ -182,12 +119,9 @@ export function buildSections( const sorted = sortWorktrees(filtered, sortMode) const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds)) - const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds)) - // Why: mobile shows pinned workspaces once in the pinned section; duplicating - // them in status/project sections makes the phone list harder to scan. - const canonicalGroupWorktrees = unpinned - const active = canonicalGroupWorktrees.filter(isWorktreeActive) - const inactive = canonicalGroupWorktrees.filter((w) => !isWorktreeActive(w)) + // Why: desktop treats Pinned as an overlay. Keeping pinned rows in canonical + // groups preserves exact cross-surface order and literal section counts. + const canonicalGroupWorktrees = sorted const sections: Section[] = [] if (pinned.length > 0) { @@ -195,19 +129,8 @@ export function buildSections( } if (groupMode === 'none') { - if (active.length > 0) { - sections.push(makeSection('all-active', 'Active', active, undefined, collapsedGroups)) - } - if (inactive.length > 0) { - sections.push( - makeSection( - 'all', - pinned.length > 0 || active.length > 0 ? 'All' : '', - inactive, - undefined, - collapsedGroups - ) - ) + if (canonicalGroupWorktrees.length > 0) { + sections.push(makeSection('all', 'All', canonicalGroupWorktrees, undefined, collapsedGroups)) } } else if (groupMode === 'repo') { const byRepo = new Map() @@ -238,7 +161,9 @@ export function buildSections( } for (const [repo, items] of byRepo) { const key = `repo:${repoIdsByName.get(repo) ?? repo}` - sections.push(makeSection(key, repo, items, undefined, collapsedGroups)) + sections.push( + makeSection(key, repo, orderMainWorktreeFirst(items), undefined, collapsedGroups) + ) } } else if (groupMode === 'workspaceStatus') { const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 8418b2753ad..4dfbc74fa53 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' export type Worktree = { @@ -5,18 +6,25 @@ export type Worktree = { workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string + hostId?: ExecutionHostId + terminalPlatform?: NodeJS.Platform repo: string branch: string displayName: string workspaceStatus?: string sortOrder?: number manualOrder?: number + lastActivityAt?: number + createdAt?: number // Why: on-disk worktree directory path. Needed by NewWorktreeModal so the // marine-creature fallback dedupes against filesystem basenames. path: string isArchived?: boolean isMainWorktree?: boolean hasHostSidebarActivity?: boolean + worktreeInstanceId?: string + lineageWorktreeInstanceId?: string + parentWorktreeInstanceId?: string parentWorktreeId?: string | null childWorktreeIds?: string[] lineageDepth?: number diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 339d5d1c9a3..d6d3af1930c 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -96,6 +96,31 @@ describe('areWorktreeListsEqual', () => { ).toBe(false) }) + it('detects desktop ordering field changes', () => { + expect( + areWorktreeListsEqual( + [worktree({ lastActivityAt: 10, createdAt: 1 })], + [worktree({ lastActivityAt: 20, createdAt: 1 })] + ) + ).toBe(false) + expect( + areWorktreeListsEqual( + [worktree({ lastActivityAt: 10, createdAt: 1 })], + [worktree({ lastActivityAt: 10, createdAt: 2 })] + ) + ).toBe(false) + }) + + it('detects resume host and terminal platform changes', () => { + expect(areWorktreeListsEqual([worktree()], [worktree({ hostId: 'ssh:box' })])).toBe(false) + expect( + areWorktreeListsEqual( + [worktree({ terminalPlatform: 'win32' })], + [worktree({ terminalPlatform: 'linux' })] + ) + ).toBe(false) + }) + it('detects lineage changes', () => { const base = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-a' }) const changedParent = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-b' }) @@ -109,6 +134,25 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual([base], [changedChildren])).toBe(false) }) + it('detects lineage instance changes', () => { + const base = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' + }) + const changedParentInstance = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'new-parent-instance' + }) + + expect(areWorktreeListsEqual([base], [changedParentInstance])).toBe(false) + }) + it('detects agent status changes', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'waiting' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index c851bda24d9..bb9df6cf9af 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -25,14 +25,21 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { left.repoId === right.repoId && left.repo === right.repo && left.branch === right.branch && + (left.hostId ?? null) === (right.hostId ?? null) && + (left.terminalPlatform ?? null) === (right.terminalPlatform ?? null) && left.displayName === right.displayName && (left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) && (left.sortOrder ?? null) === (right.sortOrder ?? null) && (left.manualOrder ?? null) === (right.manualOrder ?? null) && + (left.lastActivityAt ?? null) === (right.lastActivityAt ?? null) && + (left.createdAt ?? null) === (right.createdAt ?? null) && left.path === right.path && (left.isArchived ?? false) === (right.isArchived ?? false) && (left.isMainWorktree ?? false) === (right.isMainWorktree ?? false) && (left.hasHostSidebarActivity ?? false) === (right.hasHostSidebarActivity ?? false) && + (left.worktreeInstanceId ?? null) === (right.worktreeInstanceId ?? null) && + (left.lineageWorktreeInstanceId ?? null) === (right.lineageWorktreeInstanceId ?? null) && + (left.parentWorktreeInstanceId ?? null) === (right.parentWorktreeInstanceId ?? null) && (left.parentWorktreeId ?? null) === (right.parentWorktreeId ?? null) && areStringArraysEqual(left.childWorktreeIds ?? [], right.childWorktreeIds ?? []) && left.liveTerminalCount === right.liveTerminalCount && diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 1e3a42479dc..cb352882776 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "expo/tsconfig.base", + "extends": "expo/tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "strict": true, diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts new file mode 100644 index 00000000000..4b39e75eb72 --- /dev/null +++ b/src/main/ai-vault/cached-session-list.ts @@ -0,0 +1,101 @@ +import { join } from 'node:path' +import { scanAiVaultSessions } from './session-scanner' +import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' + +// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime +// RPC method share a single cache instance — opening the desktop panel and the +// mobile screen for the same scope must not double-scan hundreds of transcripts. +const AI_VAULT_CACHE_TTL_MS = 15_000 + +// Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable +// seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers +// path — `orca serve` never runs that path, so sourcing it there would silently +// drop managed-Codex sessions from remote/SSH results. +export type AiVaultSessionSources = { + getAdditionalCodexHomePaths?: () => readonly string[] +} + +type CachedAiVaultList = { + key: string + result: AiVaultListResult + expiresAt: number +} + +let cachedList: CachedAiVaultList | null = null +let inflightList: Promise | null = null +let inflightKey: string | null = null +let sources: AiVaultSessionSources = {} + +export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { + sources = next +} + +export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + // Scope paths change the result set, so they must be part of the cache key. + const key = JSON.stringify({ + limit: args?.limit ?? 'default', + scopePaths: args?.scopePaths ?? [] + }) + const now = Date.now() + // Why: opening this panel repeatedly should not re-parse hundreds of JSONL + // transcripts; explicit refreshes bypass the cache but not an active scan. + if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { + return cachedList.result + } + if (inflightList && inflightKey === key) { + return inflightList + } + + inflightKey = key + const additionalCodexSessionsDirs = + sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] + inflightList = (async () => + scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + }))() + .then((result) => { + cachedList = { + key, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + } + return result + }) + .finally(() => { + // Only clear tracking if it still refers to this request: a concurrent + // different-key scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } + }) + return inflightList +} + +// Exported for the subagent-transcript IPC path, which validates +// renderer-supplied paths against the same WSL-aware Claude roots the scan uses. +export async function getAiVaultWslHomeDirs(): Promise { + if (process.platform !== 'win32') { + return [] + } + const homes = await Promise.all( + (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) + ) + return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) +} + +// Why: tests reset module-level cache/source state between cases. +export function resetAiVaultSessionListCacheForTests(): void { + cachedList = null + inflightList = null + inflightKey = null + sources = {} +} diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index d02519eaff2..b2f318d8e40 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { AI_VAULT_AGENTS, + AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListArgs, type AiVaultListResult, type AiVaultSession @@ -121,7 +122,11 @@ export async function scanRuntimeAiVaultSessions( { limit: args.limit, force: args.force, - scopePaths: args.scopePaths, + // Why: cap here so the set of scanned paths is explicit on this side — + // the RPC schema CLAMPS to the same bound anyway (older hosts had no + // cap). Dropped paths only lose the older-than-recency-cap guarantee, + // never the recent sessions themselves. + scopePaths: args.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT), executionHostId }, options.timeoutMs diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index ea21dc590f6..06f8df9c39a 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -25,6 +25,29 @@ export function aiVaultScanIssueResult(args: { } } +// Why: the serving-side scan is host-local and cached once for every caller +// (desktop parent, web, mobile), so callers that address this host by a runtime +// id get the cached result restamped on the way out instead of a per-host scan. +// Mirrors the scanner's stamp recipe so ids stay stable across both paths. +export function restampAiVaultListResult( + result: AiVaultListResult, + executionHostId: ExecutionHostId +): AiVaultListResult { + return { + sessions: result.sessions.map((session) => + session.executionHostId === executionHostId + ? session + : { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } + ), + issues: result.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: result.scannedAt + } +} + export function mergeAiVaultListResults( results: readonly AiVaultListResult[], rawLimit: number | undefined diff --git a/src/main/index.ts b/src/main/index.ts index 1b91bf3696a..2ff6ca879fc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1808,6 +1808,11 @@ app.whenReady().then(async () => { // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), + // Why: source codex-home here (runs in BOTH window and serve modes) so the + // aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH + // hosts; the window-only registerCoreHandlers path never runs under serve. + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 85e547da860..5a5dbe0c6ea 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,12 +1,17 @@ import { app, ipcMain } from 'electron' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' +import { + configureAiVaultSessionSources, + getAiVaultWslHomeDirs, + listAiVaultSessions as listCachedLocalAiVaultSessions, + resetAiVaultSessionListCacheForTests, + type AiVaultSessionSources +} from '../ai-vault/cached-session-list' import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' -import { scanAiVaultSessions } from '../ai-vault/session-scanner' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult, @@ -30,8 +35,7 @@ import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 -type AiVaultHandlerOptions = { - getAdditionalCodexHomePaths?: () => readonly string[] +type AiVaultHandlerOptions = AiVaultSessionSources & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: ( environmentId: string, @@ -64,6 +68,13 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args) - } - if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] @@ -218,15 +225,13 @@ function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { } async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { - const additionalCodexSessionsDirs = - handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? - [] - return scanAiVaultSessions({ + // Why: the shared cache module owns codex-home/WSL sourcing and the local + // scan cache, so the desktop IPC path and the runtime RPC method (mobile) + // share one cache instance and one source of managed-Codex homes. + return listCachedLocalAiVaultSessions({ limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), - executionHostId: LOCAL_EXECUTION_HOST_ID + force: args?.force, + scopePaths: args?.scopePaths }) } @@ -268,6 +273,11 @@ function sshScanIssueResult(args: { export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options + // Why: configure the SAME shared cache module the runtime RPC method uses so + // there is exactly one cache instance and neither caller drops codex-home or + // WSL injection. The runtime also configures these sources from its deps + // (serve-mode reachable); this desktop path supplies the same source. + configureAiVaultSessionSources(options) ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) @@ -324,6 +334,9 @@ function resetAiVaultCacheForTests(): void { inflightList = null inflightKey = null handlerOptions = {} + // The local leg delegates to the shared cache module; reset it too so tests + // never see a scan cached by an earlier case. + resetAiVaultSessionListCacheForTests() } export const _internals = { @@ -331,13 +344,3 @@ export const _internals = { listAiVaultSubagentSessions, resetAiVaultCacheForTests } - -async function getAiVaultWslHomeDirs(): Promise { - if (process.platform !== 'win32') { - return [] - } - const homes = await Promise.all( - (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) - ) - return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) -} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4063a65547f..157442e548b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1370,6 +1370,18 @@ describe('OrcaRuntimeService', () => { expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) }) + it('reports the configured Windows terminal shell on status', () => { + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + terminalWindowsShell: 'wsl.exe' + }) + } as never) + + expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe') + }) + it('advertises browser screencast only when a renderer window is available', () => { const runtime = createRuntime() electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) @@ -18745,6 +18757,90 @@ describe('OrcaRuntimeService', () => { expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer' }) }) + it('returns the settled success for a retried clientMutationId whose response was lost', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer::pane:1', + parentTabId: 'tab-renderer', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const first = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-lost-response' + }) + // Why: the phone retries with the same key when the create response was + // lost in transit; within the retention window it must reuse the terminal. + const retried = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-lost-response' + }) + + const createRequests = send.mock.calls.filter( + ([channel]) => channel === 'terminal:requestTabCreate' + ) + expect(createRequests).toHaveLength(1) + expect(retried).toBe(first) + }) + it('does not dedupe mobile terminal creates across worktrees with the same clientMutationId', async () => { const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b` vi.mocked(listWorktrees).mockResolvedValue([ @@ -19968,6 +20064,7 @@ describe('OrcaRuntimeService', () => { workspaceKind: 'git', worktreeId: 'repo-1::/tmp/worktree-a', repoId: 'repo-1', + terminalPlatform: process.platform, repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', @@ -19991,6 +20088,7 @@ describe('OrcaRuntimeService', () => { unread: false, liveTerminalCount: 1, hasAttachedPty: true, + lastActivityAt: 0, lastOutputAt: 321, preview: 'build green', agents: [] @@ -20023,6 +20121,128 @@ describe('OrcaRuntimeService', () => { expect(summary?.linkedPR).toEqual({ number: 42, state: 'merged' }) }) + it('carries persisted worktree host ownership in mobile summaries', async () => { + const metaById = { + [TEST_WORKTREE_ID]: { + ...store.getAllWorktreeMeta()[TEST_WORKTREE_ID], + hostId: 'runtime:owner-runtime' as const + } + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ + repoId: TEST_REPO_ID, + hostId: 'runtime:owner-runtime' + }) + }) + + it('emits only instance-validated lineage parents in mobile summaries', async () => { + // Regression: shipped mobile clients trust parentWorktreeId without instance + // checks, so worktree.ps must not emit stale same-path lineage from the store. + const parentPath = '/tmp/worktree-parent' + const validChildPath = '/tmp/worktree-child-valid' + const staleChildPath = '/tmp/worktree-child-stale' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const validChildId = `${TEST_REPO_ID}::${validChildPath}` + const staleChildId = `${TEST_REPO_ID}::${staleChildPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + // The stale child path was reused by a replacement checkout. + [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }) + } + const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({ + worktreeId: childId, + worktreeInstanceId, + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + }) + const lineageById: Record = { + [validChildId]: makeLineage(validChildId, 'child-instance'), + [staleChildId]: makeLineage(staleChildId, 'old-child-instance') + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById, + getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId] + } + vi.mocked(listWorktrees).mockResolvedValue( + [parentPath, validChildPath, staleChildPath].map((path) => ({ + path, + head: 'abc', + branch: `feature/${path.split('/').pop()}`, + isBare: false, + isMainWorktree: false + })) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === validChildId)).toMatchObject({ + parentWorktreeId: parentId, + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' + }) + const staleSummary = worktrees.find((worktree) => worktree.worktreeId === staleChildId) + expect(staleSummary).toMatchObject({ + parentWorktreeId: null, + worktreeInstanceId: 'replacement-instance' + }) + expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined() + expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined() + expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({ + childWorktreeIds: [validChildId] + }) + }) + + it('reports the resolved terminal platform for WSL project mobile summaries', async () => { + await withPlatform('win32', async () => { + const runtime = new OrcaRuntimeService({ + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ + repoId: TEST_REPO_ID, + terminalPlatform: 'linux' + }) + }) + }) + it('omits worktrees hidden by the host visibility policy from mobile summaries', async () => { const hiddenExternalRepo = { ...store.getRepos()[0], diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 825caa0dc72..325c72c362e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -247,6 +247,11 @@ import { RUNTIME_PROTOCOL_VERSION, type RuntimeCapability } from '../../shared/protocol-version' +import { + configureAiVaultSessionSources, + listAiVaultSessions +} from '../ai-vault/cached-session-list' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -1183,6 +1188,9 @@ function getAgentLaunchPlatformForRepo( return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' } +// Why: long enough for a phone to reconnect and retry a create whose response +// was lost, short enough that an intentional later re-resume forks fresh. +const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' @@ -2391,6 +2399,11 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + // Why: codex-home paths for the Agent Session History scan must be sourced + // here, not via the window-only registerCoreHandlers path — that path never + // runs under `orca serve`, so remote/SSH hosts would silently drop + // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. + getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record } ) { @@ -2400,6 +2413,14 @@ export class OrcaRuntimeService { this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + // Why: configure the shared AiVault scan cache from a serve-mode-reachable + // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions + // even on headless `orca serve` hosts where registerCoreHandlers never runs. + if (deps?.getAdditionalAiVaultCodexHomePaths) { + configureAiVaultSessionSources({ + getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths + }) + } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during // attachMainWindowServices, AFTER this service is constructed. Capturing // `getLocalPtyProvider()` at construction time would freeze a reference to @@ -2790,11 +2811,19 @@ export class OrcaRuntimeService { // must not treat browser panes as supported just because runtime RPC is up. capabilities, hostPlatform: process.platform, + terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } } + // Why: scans the transcript-owning host's disk (correct by construction over + // RPC — a remote/SSH host scans its own disk). Delegates to the one shared + // cache so the desktop panel and the mobile screen never double-scan. + listAiVaultSessions(args?: AiVaultListArgs): Promise { + return listAiVaultSessions(args) + } + setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could @@ -9684,24 +9713,40 @@ export class OrcaRuntimeService { if (!linkedPR && meta?.linkedPR != null) { linkedPR = { number: meta.linkedPR, state: 'unknown' } } + const terminalPlatform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform + // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, + // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, + // so a stale same-path entry would nest replacement checkouts under old parents. + const lineage = worktree.lineage summaries.set(worktree.id, { // Why: mobile mirrors desktop workspace grouping/order from persisted // metadata, while older runtimes may not have hydrated every field yet. workspaceKind: 'git', worktreeId: worktree.id, repoId: worktree.repoId, + ...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}), + terminalPlatform, repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), + ...(lineage?.worktreeInstanceId !== undefined + ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } + : {}), + ...(lineage?.parentWorktreeInstanceId !== undefined + ? { parentWorktreeInstanceId: lineage.parentWorktreeInstanceId } + : {}), parentWorktreeId: worktree.parentWorktreeId, childWorktreeIds: worktree.childWorktreeIds, displayName: worktree.displayName, workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: meta?.sortOrder ?? 0, ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, @@ -9741,12 +9786,15 @@ export class OrcaRuntimeService { isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), parentWorktreeId: null, childWorktreeIds: [], displayName: worktree.displayName, workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: worktree.sortOrder ?? 0, ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, @@ -16437,21 +16485,23 @@ export class OrcaRuntimeService { const mutationKey = `${worktreeSelector}\0${mutationId}` // Why: a retried create (double-tap, reconnect replay) with the same // idempotency key must return the in-flight operation instead of spawning a - // duplicate terminal. Settled entries are dropped so a later retry — after a - // failure or after the result is consumed — can start a fresh create. + // duplicate terminal. Successes are kept briefly so a retry whose response + // was lost in transit reuses the created terminal; failures are dropped + // immediately so a retry can start a fresh create. const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) if (inflight) { return inflight } const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts) this.mobileTerminalCreateByMutationId.set(mutationKey, run) - void run - .catch(() => {}) - .finally(() => { - if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { - this.mobileTerminalCreateByMutationId.delete(mutationKey) - } - }) + const drop = (): void => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + } + void run.then(() => { + setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() + }, drop) return run } diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index c7cc56a6490..d676f35e579 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -1,61 +1,228 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_METHODS } from './ai-vault' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types' -const mocks = vi.hoisted(() => ({ +const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() })) vi.mock('../../../ai-vault/session-scanner', () => ({ - scanAiVaultSessions: mocks.scanAiVaultSessions + scanAiVaultSessions })) -beforeEach(() => { - vi.clearAllMocks() - mocks.scanAiVaultSessions.mockResolvedValue({ - sessions: [], - issues: [], - scannedAt: '2026-07-04T00:00:00.000Z' +import { AI_VAULT_METHODS, AiVaultListSessionsParams } from './ai-vault' +import { + configureAiVaultSessionSources, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from '../../../ai-vault/cached-session-list' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const SCANNED_AT = '2026-06-29T00:00:00.000Z' + +function makeResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: SCANNED_AT } +} + +function makeSession(): AiVaultSession { + return { + id: 'local:claude:sess-1:/tmp/t.jsonl', + executionHostId: 'local', + agent: 'claude', + sessionId: 'sess-1', + title: 'Test session', + cwd: '/tmp', + branch: null, + model: null, + filePath: '/tmp/t.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: SCANNED_AT, + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume sess-1', + subagent: null + } +} + +function makeDispatcher(): RpcDispatcher { + // Why: the handler only needs getRuntimeId (envelope) + listAiVaultSessions, + // which delegates to the shared cache module the IPC handler also uses. + const runtime = { + getRuntimeId: () => 'test-runtime', + listAiVaultSessions: (args?: Parameters[0]) => + listAiVaultSessions(args) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) +} + +describe('aiVault.listSessions params schema', () => { + it('accepts a bounded request', () => { + const parsed = AiVaultListSessionsParams.safeParse({ + limit: 500, + force: true, + scopePaths: ['/home/user/repo'] + }) + expect(parsed.success).toBe(true) }) -}) -describe('aiVault.listSessions runtime RPC', () => { - it('scans server-local sessions under the caller supplied runtime host id', async () => { - const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') - expect(method).toBeDefined() + it('rejects a limit above the cap', () => { + const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000 }) + expect(parsed.success).toBe(false) + }) - const params = method!.params!.parse({ - limit: 25, - scopePaths: ['/srv/app'], - executionHostId: 'runtime:remote-server' - }) + it('clamps scopePaths past the cap instead of rejecting', () => { + // Why: uncapped producers (web client, pre-cap desktop parents) may exceed + // the bound; scope paths only widen discovery, so truncation is safe. + const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) + const parsed = AiVaultListSessionsParams.safeParse({ scopePaths }) + expect(parsed.success).toBe(true) + expect(parsed.data?.scopePaths).toEqual(scopePaths.slice(0, 64)) + }) - await method!.handler(params, {} as never) - - expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({ - limit: 25, - scopePaths: ['/srv/app'], - executionHostId: 'runtime:remote-server' - }) + it('rejects an over-long scopePath', () => { + const parsed = AiVaultListSessionsParams.safeParse({ scopePaths: ['/'.padEnd(5000, 'a')] }) + expect(parsed.success).toBe(false) }) it('rejects non-runtime execution host ids before dispatch', () => { - const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') - expect(method).toBeDefined() + expect( + AiVaultListSessionsParams.safeParse({ executionHostId: 'not-a-runtime-host' }).success + ).toBe(false) + expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'local' }).success).toBe(false) + expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'ssh:dev-box' }).success).toBe( + false + ) + }) +}) - expect(() => - method!.params!.parse({ - executionHostId: 'not-a-runtime-host' +describe('aiVault.listSessions handler + shared cache', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessions.mockReset() + scanAiVaultSessions.mockResolvedValue(makeResult()) + }) + + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) + + it('returns the AiVaultListResult unchanged', async () => { + const dispatcher = makeDispatcher() + const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + expect(response).toMatchObject({ ok: true, result: makeResult() }) + }) + + it('passes only the first 64 scopePaths to the scanner when a request exceeds the cap', async () => { + const dispatcher = makeDispatcher() + const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) + const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { scopePaths })) + expect(response).toMatchObject({ ok: true }) + expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({ + scopePaths: scopePaths.slice(0, 64) + }) + }) + + it('shares one cache between the IPC entry point and the RPC method', async () => { + const dispatcher = makeDispatcher() + // First call via the shared module (what the desktop IPC handler invokes). + await listAiVaultSessions({ limit: 500 }) + // Second call via the RPC method with the same cache key. + await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { + // Why: the resolving scan's cleanup must not clear tracking a concurrent + // different-key scan replaced, or re-requests start a duplicate rescan. + const deferreds: ((result: AiVaultListResult) => void)[] = [] + scanAiVaultSessions.mockImplementation( + () => new Promise((resolve) => deferreds.push(resolve)) + ) + // The scanner is invoked a microtask after the call (WSL-home await), so + // wait for each deferred to register before driving resolution order. + const scanA = listAiVaultSessions({ limit: 100 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(1)) + const scanB = listAiVaultSessions({ limit: 200 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(2)) + deferreds[0]?.(makeResult()) + await scanA + // Re-request key B while its first scan is still pending: must dedupe. + const scanBAgain = listAiVaultSessions({ limit: 200 }) + // Flush a macrotask so that a wrongly-started duplicate scan would reach the + // mock (it fires a microtask after the WSL-home await): guarded stays 2, a + // reverted guard reads 3, so this assertion — not a Promise.all hang — pins + // the fix. + await new Promise((resolve) => setTimeout(resolve)) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + deferreds[1]?.(makeResult()) + await Promise.all([scanB, scanBAgain]) + }) + + it('restamps the shared cached result as the addressed runtime host', async () => { + scanAiVaultSessions.mockResolvedValue({ + sessions: [makeSession()], + issues: [{ executionHostId: 'local', agent: 'claude', path: '/tmp', message: 'boom' }], + scannedAt: SCANNED_AT + }) + const dispatcher = makeDispatcher() + // A mobile-style caller (no executionHostId) primes the shared cache… + const localResponse = (await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { limit: 500 }) + )) as { ok: boolean; result: AiVaultListResult } + // …then a desktop/web caller addressing this host by runtime id reuses it. + const runtimeResponse = (await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + limit: 500, + executionHostId: 'runtime:remote-server' }) - ).toThrow('Invalid runtime execution host id') - expect(() => - method!.params!.parse({ - executionHostId: 'local' - }) - ).toThrow('Invalid runtime execution host id') - expect(() => - method!.params!.parse({ - executionHostId: 'ssh:dev-box' - }) - ).toThrow('Invalid runtime execution host id') + )) as { ok: boolean; result: AiVaultListResult } + + // Why: the host id must never change what is scanned — one host-local scan + // (and one cache entry) serves every caller; only the stamps differ. + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({ executionHostId: 'local' }) + + expect(localResponse.result.sessions[0]?.executionHostId).toBe('local') + expect(runtimeResponse.result.sessions[0]?.executionHostId).toBe('runtime:remote-server') + expect(runtimeResponse.result.sessions[0]?.id).toBe( + 'runtime:remote-server:claude:sess-1:/tmp/t.jsonl' + ) + expect(runtimeResponse.result.issues[0]?.executionHostId).toBe('runtime:remote-server') + }) + + it('injects codex-home dirs sourced from the runtime (serve-mode reachable)', async () => { + configureAiVaultSessionSources({ + getAdditionalCodexHomePaths: () => ['/runtime/codex/home'] + }) + const dispatcher = makeDispatcher() + await dispatcher.dispatch(makeRequest('aiVault.listSessions', {})) + const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions + // Why: the codex-home is sourced from the runtime, not the window-only + // registerCoreHandlers path, so it survives in serve mode. + expect(options.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') + expect(options.wslHomeDirs).toEqual([]) + }) + + it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { + // Why: the dispatcher test above seeds the cache module directly, so it would + // still pass if OrcaRuntimeService stopped forwarding the codex-home source. + // Construct the real runtime to lock that cross-layer wiring in place. + const runtime = new OrcaRuntimeService(null, undefined, { + getAdditionalAiVaultCodexHomePaths: () => ['/ctor/codex/home'] + }) + await runtime.listAiVaultSessions({}) + const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions + expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') }) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 49591ea1e40..50ace9d222a 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,8 +1,16 @@ import { z } from 'zod' -import { scanAiVaultSessions } from '../../../ai-vault/session-scanner' import { defineMethod, type RpcMethod } from '../core' +import { OptionalBoolean } from '../schemas' +import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' +import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { parseExecutionHostId } from '../../../../shared/execution-host' +// Why: bound limit + scopePaths so a client cannot force an unbounded scan. +// Each scopePath is a host-local match prefix (validated/capped, never used for +// traversal); the count/length caps mirror the worktree-schemas bounding style. +const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 +const AI_VAULT_LIMIT_MAX = 2000 + const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { const parsed = parseExecutionHostId(value) if (parsed?.kind === 'runtime') { @@ -15,22 +23,43 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri return z.NEVER }) -const listSessionsParamsSchema = z.object({ - limit: z.number().int().positive().optional(), - force: z.boolean().optional(), - scopePaths: z.array(z.string()).optional(), - executionHostId: executionHostIdSchema +export const AiVaultListSessionsParams = z.object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()])) + .optional(), + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() }) export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', - params: listSessionsParamsSchema, - handler: async (params) => - scanAiVaultSessions({ + params: AiVaultListSessionsParams, + handler: async (params, { runtime }) => { + const result = await runtime.listAiVaultSessions({ limit: params.limit, - scopePaths: params.scopePaths, - executionHostId: params.executionHostId + force: params.force, + scopePaths: params.scopePaths }) + // Why: web clients consume this response directly (no parent-side retag), + // so sessions must come back stamped as the runtime host they addressed. + return params.executionHostId + ? restampAiVaultListResult(result, params.executionHostId) + : result + } }) ] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f8b3c75c7cf..ace969138c4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -124,6 +124,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.selectCodex', 'accounts.subscribe', 'accounts.unsubscribe', + 'aiVault.listSessions', 'browser.back', 'browser.dialogAccept', 'browser.dialogDismiss', @@ -160,6 +161,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', 'files.writeTerminalArtifact', + 'folderWorkspace.list', 'git.abortMerge', 'git.abortRebase', 'git.bulkStage', @@ -282,6 +284,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', + 'projectGroup.list', 'repo.baseRefDefault', 'repo.gitAvailable', 'repo.hooks', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts index a7ab45e936e..e22a64e3a8a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts @@ -1,111 +1,10 @@ -import type { - AiVaultSession, - AiVaultSessionPreviewMessage -} from '../../../../shared/ai-vault-types' - -const CONVERSATION_ROLES = new Set(['user', 'assistant']) - -export type AiVaultSessionDisplayTurn = { - role: AiVaultSessionPreviewMessage['role'] - text: string - timestamp: string | null -} - -export function latestSessionConversationTurn( - session: AiVaultSession -): AiVaultSessionDisplayTurn | null { - return recentSessionConversationTurns(session, 1)[0] ?? null -} - -export function recentSessionConversationTurns( - session: AiVaultSession, - limit: number -): AiVaultSessionDisplayTurn[] { - if (limit <= 0) { - return [] - } - - return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn) -} - -export function sessionDetailConversationTurns( - session: AiVaultSession, - limit: number -): AiVaultSessionDisplayTurn[] { - if (limit <= 0) { - return [] - } - - const turns = displayableSessionPreviewMessages(session) - .map(toDisplayTurn) - .filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text)) - - return dedupeAdjacentConversationTurns(turns).slice(-limit) -} - -function turnTextMatchesSessionTitle(title: string, turnText: string): boolean { - const sessionText = normalizeSessionDisplayText(title) - const candidateText = normalizeSessionDisplayText(turnText) - if (!sessionText || !candidateText) { - return false - } - if (sessionText === candidateText) { - return true - } - const longEnough = sessionText.length >= 24 && candidateText.length >= 24 - return ( - longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) - ) -} - -function dedupeAdjacentConversationTurns( - turns: AiVaultSessionDisplayTurn[] -): AiVaultSessionDisplayTurn[] { - const deduped: AiVaultSessionDisplayTurn[] = [] - for (const turn of turns) { - const previous = deduped.at(-1) - if ( - previous && - previous.role === turn.role && - normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text) - ) { - continue - } - deduped.push(turn) - } - return deduped -} - -function normalizeSessionDisplayText(value: string): string { - return value.trim().replace(/\s+/g, ' ').toLowerCase() -} - -export function sessionModelLabel(session: AiVaultSession): string | null { - return session.model || null -} - -export function sessionPreviewSearchText(session: AiVaultSession): string { - return displayableSessionPreviewMessages(session) - .map((message) => message.text) - .join(' ') -} - -function displayableSessionPreviewMessages( - session: AiVaultSession -): AiVaultSessionPreviewMessage[] { - const conversationTurns = session.previewMessages.filter((message) => - CONVERSATION_ROLES.has(message.role) - ) - - // Why: search hits should be explainable by the preview UI; tool/system text is - // only searchable when it is the fallback preview shown for the session. - return conversationTurns.length > 0 ? conversationTurns : session.previewMessages -} - -function toDisplayTurn(message: AiVaultSessionPreviewMessage): AiVaultSessionDisplayTurn { - return { - role: message.role, - text: message.text, - timestamp: message.timestamp - } -} +// Why: the pure preview/search-text core now lives in /shared so mobile can +// reuse it (Metro can't import renderer). Re-export for renderer import parity. +export type { AiVaultSessionDisplayTurn } from '../../../../shared/ai-vault-session-display' +export { + latestSessionConversationTurn, + recentSessionConversationTurns, + sessionDetailConversationTurns, + sessionModelLabel, + sessionPreviewSearchText +} from '../../../../shared/ai-vault-session-display' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 61dd7119ffa..0ea9338c39f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -1,276 +1,19 @@ -import { - isPathInsideOrEqual, - normalizeRuntimePathSeparators -} from '../../../../shared/cross-platform-path' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { parseWslUncPath } from '../../../../shared/wsl-paths' -import type { - AiVaultAgent, - AiVaultGroup, - AiVaultScope, - AiVaultSession, - AiVaultSort -} from '../../../../shared/ai-vault-types' -import { - aiVaultAgentLabel, - isAiVaultSessionRecoverableEmpty, - isAiVaultSessionResumableContent -} from '../../../../shared/ai-vault-types' -import { sessionPreviewSearchText } from './ai-vault-session-display' -import type { AiVaultSessionProject } from './ai-vault-session-projects' - -export type AiVaultSessionFilterState = { - query: string - agents: readonly AiVaultAgent[] - scope: AiVaultScope - sort: AiVaultSort - activeWorktreePaths: readonly string[] - activeProjectKey?: string | null - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - hideEmptySessions: boolean -} - -export type AiVaultSessionGroup = { - key: string - label: string - sessions: AiVaultSession[] -} - -type ParsedQuery = { - terms: string[] - repoTerms: string[] - pathTerms: string[] -} - -export const AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES = 2 * 1024 - -export function isAiVaultSessionFilterQueryTooLarge( - query: string, - maxBytes = AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterAiVaultSessions( - sessions: readonly AiVaultSession[], - filters: AiVaultSessionFilterState -): AiVaultSession[] { - if (isAiVaultSessionFilterQueryTooLarge(filters.query)) { - return [] - } - - const agentSet = new Set(filters.agents) - const parsedQuery = parseVaultQuery(filters.query) - - return sessions - .filter((session) => { - if (!agentSet.has(session.agent)) { - return false - } - // Hide plain empty sessions, but keep sessions with resumable content - // (some parsers only learn turns from previews, e.g. Grok) and zero-turn - // sessions that still carry recoverable content (queued prompts / - // subagent transcripts) so a lost conversation is surfaced distinctly. - if ( - filters.hideEmptySessions && - !isAiVaultSessionResumableContent(session) && - !isAiVaultSessionRecoverableEmpty(session) - ) { - return false - } - if (filters.scope === 'workspace') { - const cwd = session.cwd - if ( - !cwd || - !filters.activeWorktreePaths.some((pathValue) => - isAiVaultSessionInWorkspacePath(pathValue, cwd) - ) - ) { - return false - } - } - if (filters.scope === 'project') { - if (!filters.activeProjectKey) { - return false - } - if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { - return false - } - } - return matchesQuery(session, parsedQuery, filters) - }) - .sort((left, right) => compareSessions(left, right, filters.sort)) -} - -export function groupAiVaultSessions( - sessions: readonly AiVaultSession[], - group: AiVaultGroup, - options: { - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - } = {} -): AiVaultSessionGroup[] { - const groups = new Map() - - for (const session of sessions) { - const { key, label } = getGroupIdentity(session, group, options) - const existing = groups.get(key) - if (existing) { - existing.sessions.push(session) - } else { - groups.set(key, { key, label, sessions: [session] }) - } - } - - return [...groups.values()] -} - -export function folderLabel(pathValue: string | null): string { - if (!pathValue) { - return 'Unknown location' - } - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - -export function agentLabel(agent: AiVaultAgent): string { - return aiVaultAgentLabel(agent) -} - -export function parseVaultQuery(query: string): ParsedQuery { - const terms: string[] = [] - const repoTerms: string[] = [] - const pathTerms: string[] = [] - - for (const rawToken of tokenizeQuery(query)) { - const token = rawToken.toLowerCase() - if (token.startsWith('repo:')) { - const value = token.slice('repo:'.length) - if (value) { - repoTerms.push(value) - } - continue - } - if (token.startsWith('path:')) { - const value = token.slice('path:'.length) - if (value) { - pathTerms.push(value) - } - continue - } - terms.push(token) - } - - return { terms, repoTerms, pathTerms } -} - -function matchesQuery( - session: AiVaultSession, - parsed: ParsedQuery, - filters: Pick -): boolean { - const searchable = [ - session.title, - session.sessionId, - session.agent, - session.branch, - session.model, - session.cwd, - session.filePath, - sessionPreviewSearchText(session) - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - - if (parsed.terms.some((term) => !searchable.includes(term))) { - return false - } - - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false - } - - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false - } - - return true -} - -function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number { - const leftValue = sort === 'created' ? left.createdAt : left.updatedAt - const rightValue = sort === 'created' ? right.createdAt : right.updatedAt - const leftTime = Date.parse(leftValue ?? left.modifiedAt) - const rightTime = Date.parse(rightValue ?? right.modifiedAt) - return rightTime - leftTime -} - -function getGroupIdentity( - session: AiVaultSession, - group: AiVaultGroup, - options: { - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - } -): Pick { - if (group === 'agent') { - return { key: session.agent, label: agentLabel(session.agent) } - } - if (group === 'project') { - const sessionProject = options.sessionProjectById?.get(session.id) - if (sessionProject) { - return { - key: sessionProject.key, - label: - options.projectLabelByKey?.get(sessionProject.key) || - sessionProject.label || - folderLabel(session.cwd) - } - } - } - return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } -} - -function getFolderGroupKey(pathValue: string | null): string { - return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' -} - -function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { - if (isPathInsideOrEqual(workspacePath, sessionCwd)) { - return true - } - - const workspaceWslPath = parseWslUncPath(workspacePath) - if (!workspaceWslPath) { - return false - } - - // WSL agent transcripts record Linux cwd values even when Orca stores the - // active worktree as a Windows UNC path. - return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd) -} - -function tokenizeQuery(query: string): string[] { - const tokens: string[] = [] - const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g - let match: RegExpExecArray | null - while ((match = pattern.exec(query)) !== null) { - const token = match[1] ?? match[2] ?? match[3] - if (token?.trim()) { - tokens.push(token.trim()) - } - } - return tokens -} +// Why: the pure filter/group/query core now lives in /shared so the mobile +// package can reuse it (Metro can't import renderer). Re-export for renderer +// import parity. Not a byte-for-byte move: tokenizeQuery gained quoted +// repo:/path: operator values (e.g. path:"/a/My Project"), which the old +// renderer tokenizer split on spaces. +export type { + AiVaultSessionProject, + AiVaultSessionFilterState, + AiVaultSessionGroup +} from '../../../../shared/ai-vault-session-filters' +export { + AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES, + agentLabel, + filterAiVaultSessions, + folderLabel, + groupAiVaultSessions, + isAiVaultSessionFilterQueryTooLarge, + parseVaultQuery +} from '../../../../shared/ai-vault-session-filters' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index c0734a11881..f9f13dc9c7c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -12,15 +12,11 @@ import { normalizeRuntimePathForComparison, normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' -export type AiVaultSessionProject = { - kind: 'repo' | 'folder' | 'unknown' - key: string - label: string - projectId?: string - repoId?: string - hostKey?: ExecutionHostId -} +// Why: the plain project descriptor moved to /shared (so the lifted filter core +// stays renderer-free). Re-export it here for renderer import parity. +export type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' export type AiVaultProjectContext = { activeProjectKey: string | null diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts index 228ff9ddca6..c0112fa2c41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -55,11 +55,15 @@ describe('terminal IME candidate key release guard', () => { it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => { const releases = createTerminalImePendingCandidateKeyReleases() armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) - expect(shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20)).toBe( - false - ) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) ).toBe(false) expect( shouldApplyTerminalImePendingCandidateKeyRelease( @@ -157,10 +161,18 @@ describe('terminal IME candidate key release guard', () => { armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '2' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 20 + ) ).toBe(true) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) ).toBe(true) // The first key's keyup no longer strands the second key's pending release. clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts index 1e363adcec4..858745afb3d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -11,7 +11,10 @@ type TrackerHarness = { tracker: TerminalImeCompositionTracker element: HTMLElement advance: (ms: number) => void - composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void + composition: ( + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data: string + ) => void input: (inputType: string) => void blur: () => void } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 7e5d55e4bff..1f3a78326cd 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -166,7 +166,11 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { // Post-compositionend guard: the tracker is already inactive but the // committing key's trailing press/release must still be absorbed. const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } - const windowsComposing = { ...windowsIdle, compositionActive: true, candidateKeyGuardActive: true } + const windowsComposing = { + ...windowsIdle, + compositionActive: true, + candidateKeyGuardActive: true + } it('suppresses keyboard events while Chromium reports active IME composition', () => { for (const options of [windowsIdle, linuxIdle]) { @@ -268,9 +272,9 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => { for (const options of [linuxComposing, linuxPostCompositionGuard]) { for (const key of [' ', '0', '2', '9']) { - expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options) - ).toBe(true) + expect(shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options)).toBe( + true + ) expect( shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options) ).toBe(true) @@ -296,7 +300,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('leaves Space and digits alone once the guard has expired', () => { for (const type of ['keydown', 'keyup', 'keypress']) { expect( - shouldSuppressTerminalImeKeyboardEvent(event({ type, key: ' ', code: 'Space' }), linuxIdle) + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: ' ', code: 'Space' }), + linuxIdle + ) ).toBe(false) expect( shouldSuppressTerminalImeKeyboardEvent( @@ -336,10 +343,7 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('does not apply the Linux/Sogou candidate guard to Windows', () => { expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: ' ', code: 'Space' }), - windowsComposing - ) + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), windowsComposing) ).toBe(false) expect( shouldSuppressTerminalImeKeyboardEvent( @@ -390,7 +394,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle) ).toBe(false) expect( - shouldPreventDefaultTerminalImeCandidateKey(event({ key: 'a', code: 'KeyA' }), linuxComposing) + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: 'a', code: 'KeyA' }), + linuxComposing + ) ).toBe(false) expect( shouldPreventDefaultTerminalImeCandidateKey( diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 4d5db5d7237..fb703349029 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,6 +123,27 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) + it('follows the live Windows shell for non-resumable agents in the fallback path', () => { + // Why: agents without a TUI startup plan (e.g. cursor) queue through the + // shared-builder fallback, which must quote for the live shell too (#6152). + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'cursor', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" + ) + }) + it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { // Regression: local rebuilds must forward session.filePath so OMP resumes by // path, and queued Windows commands must match the live tab shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index d71f3653fbb..f5ea23d3a55 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -137,6 +137,8 @@ export function buildAiVaultResumeStartupForWorktree(args: { platform, commandOverride: args.commandOverride, codexHome, + // Why: non-resumable agents queue through this fallback too, so it must + // quote for the live Windows shell like the startup-plan branch above. shell: queuedShell }) } diff --git a/src/shared/ai-vault-session-display.ts b/src/shared/ai-vault-session-display.ts new file mode 100644 index 00000000000..b35528ffd71 --- /dev/null +++ b/src/shared/ai-vault-session-display.ts @@ -0,0 +1,112 @@ +// Why: pure preview/search-text core for Agent Session History, lifted to +// /shared so both the renderer and the mobile package (which cannot import +// renderer modules) can build session previews from the same logic. +// INVARIANT: /shared is a leaf — this module imports only types from /shared. +import type { AiVaultSession, AiVaultSessionPreviewMessage } from './ai-vault-types' + +const CONVERSATION_ROLES = new Set(['user', 'assistant']) + +export type AiVaultSessionDisplayTurn = { + role: AiVaultSessionPreviewMessage['role'] + text: string + timestamp: string | null +} + +export function latestSessionConversationTurn( + session: AiVaultSession +): AiVaultSessionDisplayTurn | null { + return recentSessionConversationTurns(session, 1)[0] ?? null +} + +export function recentSessionConversationTurns( + session: AiVaultSession, + limit: number +): AiVaultSessionDisplayTurn[] { + if (limit <= 0) { + return [] + } + + return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn) +} + +export function sessionDetailConversationTurns( + session: AiVaultSession, + limit: number +): AiVaultSessionDisplayTurn[] { + if (limit <= 0) { + return [] + } + + const turns = displayableSessionPreviewMessages(session) + .map(toDisplayTurn) + .filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text)) + + return dedupeAdjacentConversationTurns(turns).slice(-limit) +} + +function turnTextMatchesSessionTitle(title: string, turnText: string): boolean { + const sessionText = normalizeSessionDisplayText(title) + const candidateText = normalizeSessionDisplayText(turnText) + if (!sessionText || !candidateText) { + return false + } + if (sessionText === candidateText) { + return true + } + const longEnough = sessionText.length >= 24 && candidateText.length >= 24 + return ( + longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) + ) +} + +function dedupeAdjacentConversationTurns( + turns: AiVaultSessionDisplayTurn[] +): AiVaultSessionDisplayTurn[] { + const deduped: AiVaultSessionDisplayTurn[] = [] + for (const turn of turns) { + const previous = deduped.at(-1) + if ( + previous && + previous.role === turn.role && + normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text) + ) { + continue + } + deduped.push(turn) + } + return deduped +} + +function normalizeSessionDisplayText(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase() +} + +export function sessionModelLabel(session: AiVaultSession): string | null { + return session.model || null +} + +export function sessionPreviewSearchText(session: AiVaultSession): string { + return displayableSessionPreviewMessages(session) + .map((message) => message.text) + .join(' ') +} + +function displayableSessionPreviewMessages( + session: AiVaultSession +): AiVaultSessionPreviewMessage[] { + const conversationTurns = session.previewMessages.filter((message) => + CONVERSATION_ROLES.has(message.role) + ) + + // Why: search hits should be explainable by the preview UI; tool/system text is + // only searchable when it is the fallback preview shown for the session. + return conversationTurns.length > 0 ? conversationTurns : session.previewMessages +} + +function toDisplayTurn(message: AiVaultSessionPreviewMessage): AiVaultSessionDisplayTurn { + return { + role: message.role, + text: message.text, + timestamp: message.timestamp + } +} diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts new file mode 100644 index 00000000000..47a1e10b64d --- /dev/null +++ b/src/shared/ai-vault-session-filters.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from './ai-vault-types' +import { + agentLabel, + filterAiVaultSessions, + folderLabel, + groupAiVaultSessions, + parseVaultQuery +} from './ai-vault-session-filters' +import { sessionPreviewSearchText } from './ai-vault-session-display' + +const baseSession: AiVaultSession = { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement vault filters', + cwd: '/Users/ada/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [ + { role: 'user', text: 'add the scope tabs', timestamp: null }, + { role: 'assistant', text: 'done — added Workspace/Project/All', timestamp: null } + ], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'", + subagent: null +} + +const otherSession: AiVaultSession = { + ...baseSession, + id: 'codex:2', + agent: 'codex', + sessionId: 'session-2', + title: 'Repair terminal tabs', + cwd: '/Users/ada/other/packages/ui', + branch: 'fix/terminal', + filePath: '/Users/ada/.codex/sessions/session-2.jsonl', + previewMessages: [] +} + +describe('/shared ai-vault-session-filters (lifted core)', () => { + it('filters by agent, workspace scope, and plain/repo/path query terms', () => { + expect( + filterAiVaultSessions([baseSession, otherSession], { + query: 'vault repo:repo path:app', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/repo'], + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) + + it('hides empty sessions by default and keeps non-empty ones', () => { + // A session only counts as empty without conversation previews or + // recoverable signals — preview turns alone make it resumable content. + const empty: AiVaultSession = { + ...baseSession, + id: 'claude:empty', + messageCount: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0 + } + expect( + filterAiVaultSessions([baseSession, empty], { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) + + it('groups by folder', () => { + const groups = groupAiVaultSessions([baseSession, otherSession], 'folder') + expect(groups.map((group) => group.label).sort()).toEqual(['packages/ui', 'repo/app']) + }) + + it('parses repo: and path: operators from the query', () => { + expect(parseVaultQuery('hello repo:orca path:/tmp world')).toEqual({ + terms: ['hello', 'world'], + repoTerms: ['orca'], + pathTerms: ['/tmp'] + }) + }) + + it('parses quoted repo:/path: operator values containing spaces', () => { + expect(parseVaultQuery('repo:"my repo" path:"/Users/ada/My Project"')).toEqual({ + terms: [], + repoTerms: ['my repo'], + pathTerms: ['/users/ada/my project'] + }) + }) + + it('exposes a stable agent label and folder label', () => { + expect(agentLabel('claude')).toBe('Claude') + expect(folderLabel('/Users/ada/repo/app')).toBe('repo/app') + }) + + it('builds preview search text from conversation turns', () => { + expect(sessionPreviewSearchText(baseSession)).toContain('scope tabs') + }) +}) diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts new file mode 100644 index 00000000000..c943dcf6837 --- /dev/null +++ b/src/shared/ai-vault-session-filters.ts @@ -0,0 +1,298 @@ +// Why: this is the pure filter/group/query core for Agent Session History. +// It lives in /shared (not renderer) so the mobile package can reuse it — +// Metro only watches mobile/ + repo-root src/shared, never src/renderer. +// INVARIANT: /shared is a leaf — this module must NOT import from src/renderer. +import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from './cross-platform-path' +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { parseWslUncPath } from './wsl-paths' +import type { + AiVaultAgent, + AiVaultGroup, + AiVaultScope, + AiVaultSession, + AiVaultSort +} from './ai-vault-types' +import { + aiVaultAgentLabel, + isAiVaultSessionRecoverableEmpty, + isAiVaultSessionResumableContent +} from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' +import { sessionPreviewSearchText } from './ai-vault-session-display' + +// Why: the plain project descriptor is relocated here (no runtime dep) so the +// filter-state type can reference it without dragging the renderer-located +// ai-vault-session-projects runtime logic into /shared. +export type AiVaultSessionProject = { + kind: 'repo' | 'folder' | 'unknown' + key: string + label: string + projectId?: string + repoId?: string + hostKey?: ExecutionHostId +} + +export type AiVaultSessionFilterState = { + query: string + agents: readonly AiVaultAgent[] + scope: AiVaultScope + sort: AiVaultSort + activeWorktreePaths: readonly string[] + activeProjectKey?: string | null + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + hideEmptySessions: boolean +} + +export type AiVaultSessionGroup = { + key: string + label: string + sessions: AiVaultSession[] +} + +type ParsedQuery = { + terms: string[] + repoTerms: string[] + pathTerms: string[] +} + +export const AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES = 2 * 1024 + +export function isAiVaultSessionFilterQueryTooLarge( + query: string, + maxBytes = AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +export function filterAiVaultSessions( + sessions: readonly AiVaultSession[], + filters: AiVaultSessionFilterState +): AiVaultSession[] { + if (isAiVaultSessionFilterQueryTooLarge(filters.query)) { + return [] + } + + const agentSet = new Set(filters.agents) + const parsedQuery = parseVaultQuery(filters.query) + + return sessions + .filter((session) => { + if (!agentSet.has(session.agent)) { + return false + } + // Hide plain empty sessions, but keep sessions with resumable content + // (some parsers only learn turns from previews, e.g. Grok) and zero-turn + // sessions that still carry recoverable content (queued prompts / + // subagent transcripts) so a lost conversation is surfaced distinctly. + if ( + filters.hideEmptySessions && + !isAiVaultSessionResumableContent(session) && + !isAiVaultSessionRecoverableEmpty(session) + ) { + return false + } + if (filters.scope === 'workspace') { + const cwd = session.cwd + if ( + !cwd || + !filters.activeWorktreePaths.some((pathValue) => + isAiVaultSessionInWorkspacePath(pathValue, cwd) + ) + ) { + return false + } + } + if (filters.scope === 'project') { + if (!filters.activeProjectKey) { + return false + } + if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { + return false + } + } + return matchesQuery(session, parsedQuery, filters) + }) + .sort((left, right) => compareSessions(left, right, filters.sort)) +} + +export function groupAiVaultSessions( + sessions: readonly AiVaultSession[], + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } = {} +): AiVaultSessionGroup[] { + const groups = new Map() + + for (const session of sessions) { + const { key, label } = getGroupIdentity(session, group, options) + const existing = groups.get(key) + if (existing) { + existing.sessions.push(session) + } else { + groups.set(key, { key, label, sessions: [session] }) + } + } + + return [...groups.values()] +} + +export function folderLabel(pathValue: string | null): string { + if (!pathValue) { + return 'Unknown location' + } + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function agentLabel(agent: AiVaultAgent): string { + return aiVaultAgentLabel(agent) +} + +export function parseVaultQuery(query: string): ParsedQuery { + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + + for (const rawToken of tokenizeQuery(query)) { + const token = rawToken.toLowerCase() + if (token.startsWith('repo:')) { + const value = token.slice('repo:'.length) + if (value) { + repoTerms.push(value) + } + continue + } + if (token.startsWith('path:')) { + const value = token.slice('path:'.length) + if (value) { + pathTerms.push(value) + } + continue + } + terms.push(token) + } + + return { terms, repoTerms, pathTerms } +} + +function matchesQuery( + session: AiVaultSession, + parsed: ParsedQuery, + filters: Pick +): boolean { + const searchable = [ + session.title, + session.sessionId, + session.agent, + session.branch, + session.model, + session.cwd, + session.filePath, + sessionPreviewSearchText(session) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (parsed.terms.some((term) => !searchable.includes(term))) { + return false + } + + const sessionProject = filters.sessionProjectById?.get(session.id) + const repoLabel = ( + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : folderLabel(session.cwd) + ).toLowerCase() + if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { + return false + } + + const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() + if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { + return false + } + + return true +} + +function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number { + const leftValue = sort === 'created' ? left.createdAt : left.updatedAt + const rightValue = sort === 'created' ? right.createdAt : right.updatedAt + const leftTime = Date.parse(leftValue ?? left.modifiedAt) + const rightTime = Date.parse(rightValue ?? right.modifiedAt) + return rightTime - leftTime +} + +function getGroupIdentity( + session: AiVaultSession, + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } +): Pick { + if (group === 'agent') { + return { key: session.agent, label: agentLabel(session.agent) } + } + if (group === 'project') { + const sessionProject = options.sessionProjectById?.get(session.id) + if (sessionProject) { + return { + key: sessionProject.key, + label: + options.projectLabelByKey?.get(sessionProject.key) || + sessionProject.label || + folderLabel(session.cwd) + } + } + } + return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } +} + +function getFolderGroupKey(pathValue: string | null): string { + return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' +} + +function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { + if (isPathInsideOrEqual(workspacePath, sessionCwd)) { + return true + } + + const workspaceWslPath = parseWslUncPath(workspacePath) + if (!workspaceWslPath) { + return false + } + + // WSL agent transcripts record Linux cwd values even when Orca stores the + // active worktree as a Windows UNC path. + return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd) +} + +function tokenizeQuery(query: string): string[] { + const tokens: string[] = [] + // Why: keep quoted operator values (repo:/path:) intact so labels and paths + // containing spaces still match — e.g. path:"/Users/ada/My Project". + const pattern = /(repo|path):"([^"]+)"|(repo|path):'([^']+)'|"([^"]+)"|'([^']+)'|(\S+)/gi + let match: RegExpExecArray | null + while ((match = pattern.exec(query)) !== null) { + const operator = match[1] ?? match[3] + const operatorValue = match[2] ?? match[4] + if (operator && operatorValue?.trim()) { + tokens.push(`${operator.toLowerCase()}:${operatorValue.trim()}`) + continue + } + + const token = match[5] ?? match[6] ?? match[7] + if (token?.trim()) { + tokens.push(token.trim()) + } + } + return tokens +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b0622da0481..b5f5f824602 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -25,6 +25,11 @@ export const AI_VAULT_AGENTS = [ 'kimi' ] as const satisfies readonly TuiAgent[] +// Why: the aiVault.listSessions RPC schema CLAMPS scopePaths to this bound +// (safe: scope paths only widen discovery). Producer-side caps against the same +// value are optional belt-and-braces, not required for the request to succeed. +export const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64 + export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 57db2bf2e05..287b3ced8fc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -27,6 +27,11 @@ export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = 'folder-workspace.path-status.v1' as const +// Why: signals the host exposes the Agent Session History scanner over RPC +// (aiVault.listSessions). Registered unconditionally for every build, so it is a +// STATIC capability advertised by getStatus() automatically — NOT a runtime +// conditional like browser.headless.v1. +export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // Why: signals a host owns browser pages with no renderer (headless serve via the // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. @@ -44,7 +49,8 @@ export const RUNTIME_CAPABILITIES = [ PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, - FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY + FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, + AI_VAULT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 136c58e58ac..f3328ab66dc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -63,6 +63,7 @@ export type RuntimeStatus = { capabilities?: RuntimeCapability[] remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null hostPlatform?: NodeJS.Platform + terminalWindowsShell?: string | null // Why: legacy or saved WebSocket pairings may not carry scope metadata, so // the server stamps the authenticated token scope here for status.get only. deviceScope?: DeviceScope @@ -585,18 +586,25 @@ export type RuntimeWorktreePsSummary = { workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string + hostId?: Worktree['hostId'] + terminalPlatform?: NodeJS.Platform repo: string path: string branch: string isArchived: boolean isMainWorktree: boolean hasHostSidebarActivity: boolean + worktreeInstanceId?: string + lineageWorktreeInstanceId?: string + parentWorktreeInstanceId?: string parentWorktreeId: string | null childWorktreeIds: string[] displayName: string workspaceStatus: string sortOrder: number manualOrder?: number + lastActivityAt?: number + createdAt?: number linkedIssue: number | null linkedPR: { number: number; state: string } | null linkedLinearIssue: string | null diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 02dbb03c112..8994e235cdd 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -629,7 +629,9 @@ test.describe('Chinese IME terminal chat input repro', () => { const postCompositionLog = await readImeEventLog(orcaPage) const postCompositionEndIndex = postCompositionLog.findIndex( (entry, index) => - index >= postCompositionLogStart && entry.type === 'compositionend' && entry.data === '再见' + index >= postCompositionLogStart && + entry.type === 'compositionend' && + entry.data === '再见' ) const postCompositionSelectorIndex = postCompositionLog.findIndex( (entry, index) => From dc468f0ded3a81cea84db35f6ad5a73975e4468a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:48:35 -0700 Subject: [PATCH 10/95] Fix Linux Parcel watcher crawl restart loop (#8155) Co-authored-by: Orca --- .../ipc/filesystem-watcher-ignore.test.ts | 38 +-- src/main/ipc/filesystem-watcher-ignore.ts | 51 ++-- src/main/ipc/filesystem-watcher.ts | 4 +- .../ipc/parcel-watcher-process-entry.test.ts | 218 ++++++++++++++++++ src/main/ipc/parcel-watcher-process-entry.ts | 110 +++++++-- src/main/runtime/file-watcher-host.ts | 6 +- src/main/runtime/file-watcher-worker.ts | 5 +- src/relay/fs-handler.ts | 8 +- 8 files changed, 374 insertions(+), 66 deletions(-) create mode 100644 src/main/ipc/parcel-watcher-process-entry.test.ts diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 6a3ab329fef..28f90a53a04 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT, WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' const realPlatform = process.platform @@ -11,39 +11,45 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform }) } -describe('buildParcelWatcherIgnoreOption', () => { +describe('buildParcelWatcherIgnoreOptions', () => { afterEach(() => { setPlatform(realPlatform) }) it('keeps at most 8 plain paths on macOS so FSEventStreamSetExclusionPaths succeeds', () => { setPlatform('darwin') - const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) + const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + const option = options.ignore ?? [] // @parcel/watcher passes non-glob entries to FSEventStreamSetExclusionPaths, // which rejects the WHOLE set past 8 paths — silently disabling daemon-side // exclusion of node_modules/.git churn. const plainPaths = option.filter((entry) => !entry.includes('*')) expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) - // Every ignore dir must still be covered, as a path or as a glob. - for (const dir of WATCHER_IGNORE_DIRS) { - expect( - option.some((entry) => entry === dir || entry === `**/${dir}` || entry === `**/${dir}/**`) - ).toBe(true) + expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) + const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)') + for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) { + expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true) } + expect(options.ignoreGlobs?.[0]).not.toContain('?!') }) - it('expands to nested globs on Linux/Windows so nested node_modules/.git are excluded', () => { + it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => { for (const platform of ['linux', 'win32'] as const) { setPlatform(platform) - const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) - // @parcel/watcher resolves plain names to top-level-only absolute paths on - // these platforms, so nested dirs must be matched via depth-agnostic globs. + const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + expect(options.ignore).toBeUndefined() + expect(options.ignoreGlobs).toHaveLength(1) + const source = options.ignoreGlobs![0] + expect(source).not.toContain('?!') + const regex = new RegExp(source) for (const dir of WATCHER_IGNORE_DIRS) { - expect(option).toContain(`**/${dir}`) - expect(option).toContain(`**/${dir}/**`) + expect(regex.test(dir)).toBe(true) + expect(regex.test(`packages/app/${dir}`)).toBe(true) + expect(regex.test(`packages\\app\\${dir}\\nested\\file.ts`)).toBe(platform === 'win32') } - // No plain (glob-free) entries — those would only exclude the top level. - expect(option.every((entry) => entry.includes('*'))).toBe(true) + expect(regex.test('packages/app/.github/workflows')).toBe(false) + expect(regex.test('packages/app/node_modules-cache/file.ts')).toBe(false) + expect(regex.test('project\\node_modules/file.ts')).toBe(platform === 'win32') } }) }) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 9828f4a5911..7c483814c30 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -23,19 +23,42 @@ export const WATCHER_IGNORE_DIRS: string[] = [ // therefore meaningful: the first 8 get true daemon-side exclusion on macOS. export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 -export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] { +export type ParcelWatcherIgnoreOptions = { + ignore?: string[] + // @parcel/watcher's wrapper preserves this native option even though its + // public TypeScript declaration omits it. See parcel-bundler/watcher#244. + ignoreGlobs?: string[] +} + +function escapeRegex(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') +} + +function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string { + const alternatives = ignoreDirs.map(escapeRegex).join('|') + if (process.platform === 'win32') { + return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$` + } + // Why: backslash is a legal POSIX filename character, not a path separator. + return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$` +} + +export function buildParcelWatcherIgnoreOptions( + ignoreDirs: readonly string[] +): ParcelWatcherIgnoreOptions { + if (ignoreDirs.length === 0) { + return {} + } if (process.platform !== 'darwin') { - // Linux/Windows: @parcel/watcher resolves plain names to top-level-only - // absolute paths, so nested node_modules/.git/build/etc. (monorepos) get - // fully watched — on Linux that means one inotify watch per nested dir, - // exhausting fs.inotify.max_user_watches. Nested globs match at any depth - // (and **/dir still matches the top-level dir), pruning those subtrees. - return ignoreDirs.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) - } - return [ - ...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT), - ...ignoreDirs - .slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) - .flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) - ] + // Why: Parcel 2.5.6 turns leading-** globs into nested-lookahead regexes + // that are pathological in native std::regex (10-17x slower upstream). + // One component-aware regex preserves nested pruning without that CPU cost. + return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } + } + const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + return { + ignore: [...daemonExcludedDirs], + ...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {}) + } } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 56ab2aa2981..224c348b871 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -18,7 +18,7 @@ import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watc // events never leave the OS/daemon. This list is separate from the File // Explorer display filter (which only hides rows). Directories like `dist` // and `build` remain visible in the tree but will not auto-refresh. -import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOption } from './filesystem-watcher-ignore' +import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' // ── Debounce helpers ───────────────────────────────────────────────── @@ -293,7 +293,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise ({ + subscribeMock: vi.fn(), + writeFileSyncMock: vi.fn() +})) + +vi.mock('node:fs', () => ({ + mkdtempSync: vi.fn(() => '/tmp/orca-watcher-canary-test'), + rmSync: vi.fn(), + writeFileSync: writeFileSyncMock +})) +vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock })) + +describe('parcel watcher process canary', () => { + let originalMessageListeners: ReturnType + let originalExitListeners: ReturnType + let originalDisconnectListeners: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + subscribeMock.mockReset() + writeFileSyncMock.mockReset() + originalMessageListeners = process.listeners('message') + originalExitListeners = process.listeners('exit') + originalDisconnectListeners = process.listeners('disconnect') + }) + + afterEach(() => { + for (const listener of process.listeners('message')) { + if (!originalMessageListeners.includes(listener)) { + process.off('message', listener) + } + } + for (const listener of process.listeners('exit')) { + if (!originalExitListeners.includes(listener)) { + process.off('exit', listener) + } + } + for (const listener of process.listeners('disconnect')) { + if (!originalDisconnectListeners.includes(listener)) { + process.off('disconnect', listener) + } + } + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('does not restart while a native subscription is still crawling', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce(new Promise(() => undefined)) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/large-repo', opts: {} }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('invalidates an outstanding probe when another subscription starts crawling', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce(new Promise(() => undefined)) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10_000) + expect(writeFileSyncMock).toHaveBeenCalledTimes(1) + + process.emit('message', { op: 'subscribe', id: 2, dir: '/large-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(20_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(1) + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('still restarts when unsubscribe deadlocks while another root remains live', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn(() => new Promise(() => undefined)) }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('drains active crawls before starting teardown deadlock detection', async () => { + let finishSecondCrawl: + | ((subscription: { unsubscribe: () => Promise }) => void) + | undefined + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: deadlockedUnsubscribe }) + .mockReturnValueOnce( + new Promise((resolve) => { + finishSecondCrawl = resolve + }) + ) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 1 }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(deadlockedUnsubscribe).not.toHaveBeenCalled() + expect(writeFileSyncMock).not.toHaveBeenCalled() + + finishSecondCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(30_000) + + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('keeps later crawls queued behind teardown deadlock detection', async () => { + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: deadlockedUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + + process.emit('message', { op: 'subscribe', id: 3, dir: '/repo-c', opts: {} }) + await vi.advanceTimersByTimeAsync(30_000) + + expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('reserves teardown order while its subscription is still crawling', async () => { + let finishCrawl: ((subscription: { unsubscribe: () => Promise }) => void) | undefined + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce( + new Promise((resolve) => { + finishCrawl = resolve + }) + ) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/live-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/crawling-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 2 }) + process.emit('message', { op: 'subscribe', id: 3, dir: '/later-repo', opts: {} }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(writeFileSyncMock).not.toHaveBeenCalled() + + finishCrawl?.({ unsubscribe: deadlockedUnsubscribe }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(30_000) + + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('still restarts after consecutive missed events once every subscription is live', async () => { + subscribeMock.mockResolvedValue({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) +}) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index 03676721df2..6a296eb2af9 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -16,6 +16,7 @@ export type WatcherProcessEvent = { export type WatcherProcessSubscribeOptions = { ignore?: string[] + ignoreGlobs?: string[] backend?: string } @@ -45,7 +46,7 @@ const CANARY_INTERVAL_MS = 10_000 const CANARY_EVENT_TIMEOUT_MS = 5_000 const CANARY_MAX_MISSES = 2 -async function startCanary(hasLiveSubscriptions: () => boolean): Promise { +async function startCanary(getStableActivityRevision: () => number | null): Promise { let canaryDir: string let lastEventAt = 0 try { @@ -79,9 +80,11 @@ async function startCanary(hasLiveSubscriptions: () => boolean): Promise { let misses = 0 setInterval(() => { - // An idle watcher process can't wedge anything visible; only probe while - // roots are subscribed so an idle child doesn't restart pointlessly. - if (!hasLiveSubscriptions()) { + // Why: Parcel holds its shared backend mutex throughout each initial tree + // crawl, which legitimately starves canary delivery. Only apply the 5 s + // event SLA after every requested subscription has finished crawling. + const activityRevision = getStableActivityRevision() + if (activityRevision === null) { misses = 0 return } @@ -92,6 +95,12 @@ async function startCanary(hasLiveSubscriptions: () => boolean): Promise { return } setTimeout(() => { + // A root may start crawling after this probe was written. Invalidate the + // probe instead of misclassifying lifecycle work as a delivery deadlock. + if (getStableActivityRevision() !== activityRevision) { + misses = 0 + return + } if (lastEventAt >= probedAt) { misses = 0 return @@ -120,6 +129,29 @@ function main(): void { // that races a still-crawling subscribe awaits it instead of leaking the // native handle — on Windows a leaked handle keeps the worktree dir locked. const subscriptions = new Map>() + const liveSubscriptionIds = new Set() + let pendingSubscriptionCrawls = 0 + let subscriptionActivityRevision = 0 + let nativeLifecycleTail = Promise.resolve() + + const beginSubscriptionCrawl = (): void => { + pendingSubscriptionCrawls++ + subscriptionActivityRevision++ + } + + const finishSubscriptionCrawl = (): void => { + pendingSubscriptionCrawls-- + subscriptionActivityRevision++ + } + + const runNativeWatcherLifecycleExclusive = (operation: () => Promise): Promise => { + const ready = nativeLifecycleTail + let release: () => void + nativeLifecycleTail = new Promise((resolve) => { + release = resolve + }) + return ready.then(operation).finally(() => release()) + } const handleSubscribe = async ( id: number, @@ -127,43 +159,66 @@ function main(): void { opts: WatcherProcessSubscribeOptions ): Promise => { try { - const watcher = await import('@parcel/watcher') - const subscription = await watcher.subscribe( - dir, - (err, events) => { - if (err) { - send({ op: 'watch-error', id, message: errorMessage(err) }) - return - } - if (events.length > 0) { - send({ - op: 'events', - id, - events: events.map((event) => ({ type: event.type, path: event.path })) - }) - } - }, - opts as ParcelWatcher.Options - ) + const subscription = await runNativeWatcherLifecycleExclusive(async () => { + beginSubscriptionCrawl() + try { + const watcher = await import('@parcel/watcher') + return await watcher.subscribe( + dir, + (err, events) => { + if (err) { + send({ op: 'watch-error', id, message: errorMessage(err) }) + return + } + if (events.length > 0) { + send({ + op: 'events', + id, + events: events.map((event) => ({ type: event.type, path: event.path })) + }) + } + }, + opts as ParcelWatcher.Options + ) + } finally { + finishSubscriptionCrawl() + } + }) + // An unsubscribe can remove the record while subscribe() is crawling. + // Only advertise it as live if it is still owned by this process. + if (subscriptions.has(id)) { + liveSubscriptionIds.add(id) + } send({ op: 'subscribed', id }) return subscription } catch (err) { subscriptions.delete(id) + liveSubscriptionIds.delete(id) send({ op: 'subscribe-failed', id, message: errorMessage(err) }) return null } } const handleUnsubscribe = async (id: number): Promise => { + // Why: invalidate a probe already in flight, but keep future probes active. + // A native teardown deadlock is the failure the canary exists to recover. + subscriptionActivityRevision++ const pending = subscriptions.get(id) subscriptions.delete(id) + liveSubscriptionIds.delete(id) try { - const subscription = await pending - await subscription?.unsubscribe() + // Why: Parcel already serializes crawl and teardown on one backend mutex. + // Mirror that ordering here so neither operation can mask a canary miss. + await runNativeWatcherLifecycleExclusive(async () => { + const subscription = await pending + await subscription?.unsubscribe() + }) } catch (err) { process.stderr.write( `[parcel-watcher-process] unsubscribe ${id} failed: ${errorMessage(err)}\n` ) + } finally { + subscriptionActivityRevision++ } send({ op: 'unsubscribed', id }) } @@ -181,7 +236,12 @@ function main(): void { } }) - void startCanary(() => subscriptions.size > 0) + void startCanary(() => { + if (pendingSubscriptionCrawls > 0 || liveSubscriptionIds.size === 0) { + return null + } + return subscriptionActivityRevision + }) // Why: if the host dies (or kills us during shutdown), exit immediately — // process death releases every native watcher handle without running the diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 3bbfc0ab1e0..f368ea270e2 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -13,10 +13,10 @@ import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-wa // exclusions silently fail and fseventsd delivers full node_modules churn. import { WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' -const RUNTIME_FILE_WATCH_IGNORE = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) +const RUNTIME_FILE_WATCH_IGNORE_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) // Why: clean teardown is async (the worker awaits subscription.unsubscribe() // before closing its port and exiting). Wait this long for the worker to exit on @@ -66,7 +66,7 @@ export function watchFileExplorerInWorker( ): Promise<() => Promise> { return new Promise((resolve, reject) => { const worker = new Worker(getFileWatcherWorkerPath(), { - workerData: { rootPath, ignore: RUNTIME_FILE_WATCH_IGNORE } + workerData: { rootPath, ignoreOptions: RUNTIME_FILE_WATCH_IGNORE_OPTIONS } }) let ready = false diff --git a/src/main/runtime/file-watcher-worker.ts b/src/main/runtime/file-watcher-worker.ts index ec7e9141b16..9fd8e17cb77 100644 --- a/src/main/runtime/file-watcher-worker.ts +++ b/src/main/runtime/file-watcher-worker.ts @@ -10,13 +10,14 @@ import { stat } from 'node:fs/promises' import { parentPort, workerData } from 'node:worker_threads' import type * as ParcelWatcher from '@parcel/watcher' import type { FsChangeEvent } from '../../shared/types' +import type { ParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200 const RUNTIME_FILE_WATCH_STAT_CONCURRENCY = 8 type FileWatcherWorkerData = { rootPath: string - ignore: string[] + ignoreOptions: ParcelWatcherIgnoreOptions } // Messages the worker sends back to the host. @@ -117,7 +118,7 @@ async function main(): Promise { // rejection that crashes the worker silently. Surface it instead. .catch((err: unknown) => reportWatchError(err)) }, - { ignore: data.ignore } + data.ignoreOptions as ParcelWatcher.Options ) // The crawl finished and the subscription is live. diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index f6417b38d3c..6074fad2e2b 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -36,7 +36,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' import { WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from '../main/ipc/filesystem-watcher-ignore' type WatchState = { @@ -461,9 +461,9 @@ export class FsHandler { })) this.dispatcher.notify('fs.changed', { events: mapped }) }, - // Why: align remote-Linux watchers with the shared nested-glob exclusion - // so nested node_modules/.git don't exhaust inotify on large codebases. - { ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) } + // Why: align remote watchers with the shared nested exclusion so + // generated trees neither exhaust inotify nor trigger slow glob regexes. + buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) ) watchState.unwatchFn = () => { void subscription.unsubscribe() From 03a67370896d5f71a361921ddbc1f540acf2c137 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:50:38 -0700 Subject: [PATCH 11/95] Fix Windows terminal daemon death after workspace close (#8140) --- .github/workflows/computer-e2e.yml | 6 + config/reliability-gates.jsonc | 77 +++-- config/scripts/computer-e2e-workflow.test.mjs | 18 ++ .../windows-daemon-workspace-close-repro.mjs | 271 ++++++++++++++++++ src/main/daemon/daemon-pty-adapter.test.ts | 99 ++++++- src/main/daemon/daemon-pty-adapter.ts | 112 +++++--- src/main/daemon/daemon-server.ts | 4 +- src/main/daemon/history-manager.ts | 7 + src/main/daemon/pty-subprocess.test.ts | 26 ++ src/main/daemon/pty-subprocess.ts | 4 +- src/main/daemon/session.ts | 10 + src/main/daemon/terminal-host.ts | 5 + src/main/daemon/types.ts | 13 +- 13 files changed, 585 insertions(+), 67 deletions(-) create mode 100644 config/scripts/windows-daemon-workspace-close-repro.mjs diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 218ff6a6c82..58cdbb36d1c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -11,6 +11,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain # Node, so any change to the daemon bundle graph or the main build must @@ -111,6 +112,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: workspace removal overlaps graceful renderer teardown with the + # forced main-process sweep; exercise that exact pair against real ConPTY. + - name: Windows daemon workspace-close repro + if: runner.os == 'Windows' + run: node config/scripts/windows-daemon-workspace-close-repro.mjs - if: runner.os == 'Linux' env: ORCA_COMPUTER_E2E: '1' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3b8f53d0439..30940476103 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -969,7 +969,7 @@ "id": "terminal-platform.windows-conpty-liveness", "title": "Windows ConPTY terminals stay input-live, render-live, and geometry-live", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-platform", "layer": "windows-electron-conpty", "surfaces": [ @@ -987,34 +987,77 @@ "daemon", "wsl" ], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "coveredPlatforms": [ + "windows" + ], + "coveredProviders": [ + "daemon" + ], + "coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6541", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/6876", "https://github.com/stablyai/orca/pull/6968", "https://github.com/stablyai/orca/pull/6970", - "https://github.com/stablyai/orca/pull/6999" + "https://github.com/stablyai/orca/pull/6999", + "https://github.com/stablyai/orca/issues/8048" + ], + "invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", + "oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.", + "commands": [ + "pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts", + "pnpm build:electron-vite && node config/scripts/windows-daemon-workspace-close-repro.mjs", + "node config/scripts/windows-daemon-workspace-close-repro.mjs" + ], + "testFiles": [ + "src/main/daemon/pty-subprocess.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "config/scripts/windows-daemon-workspace-close-repro.mjs" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "graceful kill followed by force and dispose invokes Windows node-pty kill exactly once and never retries the dead child PID" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "the first checkpoint orders recovered scrollback before synchronously emitted fresh-shell startup output", + "a failed atomic history seed remains non-authoritative across adapter restart and cannot overwrite the recovery files" + ] + }, + { + "file": "config/scripts/windows-daemon-workspace-close-repro.mjs", + "assertions": [ + "all 25 victim sessions and OS PIDs are reaped while the built daemon PID and an unrelated witness PowerShell remain alive" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "windows", + "command": "node config/scripts/windows-daemon-workspace-close-repro.mjs", + "result": "passed", + "durationSeconds": 7.6, + "summary": "All 25 victim sessions and OS PIDs were reaped while the built daemon and witness PTY survived the real ConPTY workspace-close races. The double-close, history ordering, and seed-failure restart regressions produced intentional red failures before their fixes and passed afterward." + } ], - "invariant": "Windows local and daemon terminals must spawn with the intended shell, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", - "oracle": "A Windows live gate proves shell echo, command submission, keyboard reset, resize readback, cursor visibility, and CJK/wide-glyph redraw using PTY input logs plus visible buffer/pixel evidence.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { "p95Seconds": 90, "scope": "Windows focused Electron ConPTY gate" }, "flakeHistory": { - "status": "not-started", - "evidence": "Windows live E2E remains a known weak area; gate must soak before blocking." + "status": "unknown", + "evidence": "The built-daemon issue #8048 harness passed locally once and is wired into Windows PR CI; it needs repeated CI history before promotion." }, "redGreenEvidence": { - "status": "missing", - "evidence": "Needs proof against stale keyboard-protocol mode, shell resolution failure, dropped resize, and CJK repaint regression." + "status": "partial", + "evidence": "The ConPTY double-close and cold-restore re-anchor assertions were each observed failing before the fix and passing afterward. Keyboard protocol, shell resolution, resize, and CJK repaint still need red/green proof." }, "performanceBudget": { "required": true, @@ -1026,8 +1069,8 @@ "Split shell parity, keyboard reset, and CJK repaint into smaller gates if a combined gate is flaky." ], "knownGaps": [ - "No manifest command yet.", - "Real IME composition may require a separate lower-layer/native-text-forwarding gate." + "Real IME composition may require a separate lower-layer/native-text-forwarding gate.", + "The built-daemon harness proves process/session liveness but not renderer pixels; visible shell input, resize, cursor, and CJK repaint remain uncovered." ], "demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only." }, diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 3d4fbaecddd..3ef41583217 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -127,6 +127,23 @@ describe('computer-use e2e workflow', () => { expect(daemonSmokeIndex).toBeGreaterThan(buildIndex) }) + it('runs the Windows workspace-close daemon repro after the main build', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const steps = workflow.jobs['native-smoke'].steps + const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite') + const reproIndex = steps.findIndex( + (step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs' + ) + + expect(reproIndex).toBeGreaterThan(buildIndex) + expect(steps[reproIndex].if).toBe("runner.os == 'Windows'") + expect(workflow.on.pull_request.paths).toContain( + 'config/scripts/windows-daemon-workspace-close-repro.mjs' + ) + }) + it('re-runs the native-smoke job when the daemon bundle graph changes', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -136,6 +153,7 @@ describe('computer-use e2e workflow', () => { expect(triggerPaths).toEqual( expect.arrayContaining([ 'config/scripts/daemon-boot-smoke.mjs', + 'config/scripts/windows-daemon-workspace-close-repro.mjs', 'electron.vite.config.ts', 'build-plugins/**', 'src/main/daemon/**' diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs new file mode 100644 index 00000000000..be920ca7192 --- /dev/null +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -0,0 +1,271 @@ +/** + * Reproduces issue #8048 against the built daemon on Windows. + * + * A witness PowerShell stays alive while victim sessions receive the same + * graceful-then-immediate kill pair emitted when Orca closes a workspace. + * The daemon PID and witness session must survive every iteration. + */ +import { fork } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { connect } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const projectDir = resolve(import.meta.dirname, '../..') +const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js') +const iterations = Number(process.env.ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS ?? 25) +const requestTimeoutMs = 15_000 + +function log(message) { + process.stdout.write(`[windows-daemon-workspace-close] ${message}\n`) +} + +function readProtocolVersion() { + const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) + if (!match) { + throw new Error('Could not read the daemon protocol version') + } + return Number(match[1]) +} + +function createRpcClient(socketPath, tokenPath) { + const socket = connect(socketPath) + const pending = new Map() + let buffer = '' + let requestId = 0 + let helloResolve + let helloReject + const hello = new Promise((resolveHello, rejectHello) => { + helloResolve = resolveHello + helloReject = rejectHello + }) + + const rejectPending = (error) => { + helloReject(error) + for (const { reject, timer } of pending.values()) { + clearTimeout(timer) + reject(error) + } + pending.clear() + } + + socket.on('error', rejectPending) + socket.on('close', () => rejectPending(new Error('Daemon control socket closed'))) + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8') + let newline = buffer.indexOf('\n') + while (newline !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + const message = JSON.parse(line) + if (message.type === 'hello') { + if (message.ok) { + helloResolve() + } else { + helloReject(new Error(message.error ?? 'Daemon rejected hello')) + } + } else if (message.id) { + const request = pending.get(message.id) + if (request) { + pending.delete(message.id) + clearTimeout(request.timer) + if (message.ok) { + request.resolve(message.payload) + } else { + request.reject(new Error(message.error ?? 'Daemon request failed')) + } + } + } + newline = buffer.indexOf('\n') + } + }) + + const connected = new Promise((resolveConnected, rejectConnected) => { + socket.once('connect', resolveConnected) + socket.once('error', rejectConnected) + }).then(() => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: readProtocolVersion(), + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: randomUUID(), + role: 'control' + })}\n` + ) + return hello + }) + + return { + async request(type, payload) { + await connected + const id = `repro-${++requestId}` + return new Promise((resolveRequest, rejectRequest) => { + const timer = setTimeout(() => { + pending.delete(id) + rejectRequest(new Error(`Daemon request ${type} timed out`)) + }, requestTimeoutMs) + pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }) + socket.write(`${JSON.stringify({ id, type, ...(payload ? { payload } : {}) })}\n`) + }) + }, + close() { + socket.destroy() + } + } +} + +function waitForReady(child, stderr) { + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`Daemon readiness timed out.\n${stderr()}`)), + requestTimeoutMs + ) + child.on('message', (message) => { + if (message?.type === 'ready') { + clearTimeout(timer) + resolveReady() + } + }) + child.once('exit', (code, signal) => { + clearTimeout(timer) + rejectReady( + new Error(`Daemon exited before readiness (code=${code}, signal=${signal}).\n${stderr()}`) + ) + }) + }) +} + +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = new Promise((resolveExit) => child.once('exit', resolveExit)) + child.kill('SIGTERM') + await Promise.race([ + exited, + new Promise((resolveTimeout) => + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + resolveTimeout() + }, 5_000) + ) + ]) +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (error?.code === 'ESRCH') { + return false + } + throw error + } +} + +async function waitForVictimExit(rpc, sessionId, pid) { + const deadline = Date.now() + requestTimeoutMs + while (Date.now() < deadline) { + const { sessions } = await rpc.request('listSessions') + const sessionAlive = sessions.some((session) => session.sessionId === sessionId) + if (!sessionAlive && !isProcessAlive(pid)) { + return sessions + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)) + } + throw new Error(`Victim ${sessionId} or OS pid ${pid} was not reaped`) +} + +async function main() { + if (process.platform !== 'win32') { + log('SKIP: Windows ConPTY is required') + return + } + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`) + } + + const scratch = mkdtempSync(join(tmpdir(), 'orca-windows-daemon-close-')) + const socketPath = `\\\\.\\pipe\\orca-daemon-close-${process.pid}-${randomUUID()}` + const tokenPath = join(scratch, 'daemon.token') + const daemonLogPath = join(scratch, 'daemon.log') + const child = fork( + entryPath, + ['--socket', socketPath, '--token', tokenPath, '--log-file', daemonLogPath], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + windowsHide: true, + env: { ...process.env, ORCA_USER_DATA_PATH: scratch } + } + ) + const daemonPid = child.pid + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + let rpc + + try { + await waitForReady(child, () => stderr) + rpc = createRpcClient(socketPath, tokenPath) + const witnessId = `repro-witness@@${randomUUID().slice(0, 8)}` + await rpc.request('createOrAttach', { + sessionId: witnessId, + cols: 80, + rows: 24, + cwd: projectDir, + shellOverride: 'powershell.exe' + }) + + for (let index = 0; index < iterations; index += 1) { + const victimId = `repro-victim-${index}@@${randomUUID().slice(0, 8)}` + const victim = await rpc.request('createOrAttach', { + sessionId: victimId, + cols: 80, + rows: 24, + cwd: projectDir, + shellOverride: 'powershell.exe' + }) + if (!Number.isInteger(victim.pid) || victim.pid <= 0) { + throw new Error(`Victim ${victimId} did not return a valid OS pid`) + } + + // Why: sending both RPCs before awaiting either preserves the renderer + // unmount/worktree-sweep overlap that produced issue #8048. + const graceful = rpc.request('kill', { sessionId: victimId, immediate: false }) + const forced = rpc.request('kill', { sessionId: victimId, immediate: true }) + await Promise.all([graceful, forced]) + + const sessions = await waitForVictimExit(rpc, victimId, victim.pid) + if (child.pid !== daemonPid || child.exitCode !== null) { + throw new Error(`Daemon PID ${daemonPid} exited while closing victim ${index}`) + } + if (!sessions.some((session) => session.sessionId === witnessId && session.isAlive)) { + throw new Error(`Witness PTY disappeared while closing victim ${index}`) + } + } + + await rpc.request('kill', { sessionId: witnessId, immediate: true }) + log( + `PASS: ${iterations} victim sessions/PIDs were reaped while daemon ${daemonPid} and the witness PTY survived` + ) + } catch (error) { + const daemonLog = existsSync(daemonLogPath) ? readFileSync(daemonLogPath, 'utf8') : '' + throw new Error(`${error.message}\nstderr:\n${stderr}\ndaemon.log:\n${daemonLog}`) + } finally { + rpc?.close() + await stopChild(child) + rmSync(scratch, { recursive: true, force: true }) + } +} + +main().catch((error) => { + process.stderr.write(`[windows-daemon-workspace-close] FAIL: ${error.message}\n`) + process.exitCode = 1 +}) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index af7756e45e7..f369980bc5e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' +import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' @@ -29,7 +30,7 @@ function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-')) } -function createMockSubprocess(): SubprocessHandle & { +function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -47,6 +48,9 @@ function createMockSubprocess(): SubprocessHandle & { signal: vi.fn(), onData(cb) { onDataCb = cb + if (dataOnSubscribe) { + cb(dataOnSubscribe) + } }, onExit(cb) { onExitCb = cb @@ -86,8 +90,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { env?: Record command?: string } | null + let subprocessDataOnSubscribe: string | undefined beforeEach(async () => { + subprocessDataOnSubscribe = undefined dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -97,7 +103,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { tokenPath, spawnSubprocess: (opts) => { lastSpawnOpts = opts - lastSubprocess = createMockSubprocess() + lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe) return lastSubprocess } }) @@ -938,7 +944,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) }) - it('persists final take records that are not represented in the snapshot', async () => { + itOnPosix('persists final take records that are not represented in the snapshot', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) const { id } = await historyAdapter.spawn({ @@ -1335,9 +1341,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { 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 unseeded race winner is replaced before exposure, so the retained + // shell uses the recovered dimensions as well as the recovered history. + expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 100, rows: 30 }) // The recovery data must survive — openSession would have deleted it. expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true) const internals = historyAdapter as unknown as { @@ -1520,6 +1526,87 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(meta.rows).toBe(24) }) + it('keeps recovered scrollback when the fresh daemon session re-anchors history', async () => { + const sessionId = 'cold-restore-reanchor' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-07-10T08:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'recovered marker\r\n') + subprocessDataOnSubscribe = 'fresh shell output\r\n' + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore?.scrollback).toContain('recovered marker') + + const internals = historyAdapter as unknown as { + checkpointSessions(sessionIds: Iterable): Promise> + } + await internals.checkpointSessions([sessionId]) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8')) + + expect(checkpoint.snapshotAnsi).toContain('recovered marker') + expect(checkpoint.snapshotAnsi).toContain('fresh shell output') + expect(checkpoint.snapshotAnsi.indexOf('recovered marker')).toBeLessThan( + checkpoint.snapshotAnsi.indexOf('fresh shell output') + ) + }) + + it('keeps recovery persistence suspended across an adapter restart after seed failure', async () => { + const sessionId = 'cold-restore-seed-failure' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-07-10T08:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'recovered marker\r\n') + + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const originalWriteSync = HeadlessEmulator.prototype.writeSync + const writeSyncSpy = vi + .spyOn(HeadlessEmulator.prototype, 'writeSync') + .mockImplementation(function (this: HeadlessEmulator, data) { + return data.includes('recovered marker') ? false : originalWriteSync.call(this, data) + }) + try { + await first.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('fresh-only output\r\n') + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore?.scrollback).toContain('recovered marker') + const internals = historyAdapter as unknown as { + checkpointSessions(sessionIds: Iterable): Promise> + } + await internals.checkpointSessions([sessionId]) + + expect(existsSync(join(sessionDir, 'checkpoint.json'))).toBe(false) + expect(readFileSync(join(sessionDir, 'scrollback.bin'), 'utf8')).toContain( + 'recovered marker' + ) + } finally { + writeSyncSpy.mockRestore() + } + }) + it('does not cold-restore for clean shutdown (endedAt set)', async () => { const sessionId = 'clean-exit' 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 648870abe1c..cac7f14e480 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -36,6 +36,14 @@ type ColdRestorePayload = { oscLinks?: TerminalOscLinkRange[] } +function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { + // Why: alt-screen snapshots represent the TUI buffer; prefer its normal + // scrollback so a dead TUI is not revived as the fresh shell's active screen. + return restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi +} + export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string @@ -166,9 +174,9 @@ export class DaemonPtyAdapter implements IPtyProvider { restoreInfo = this.historyReader.detectColdRestore(sessionId) } } - const effectiveCwd = restoreInfo?.cwd ?? opts.cwd - const effectiveCols = restoreInfo?.cols ?? opts.cols - const effectiveRows = restoreInfo?.rows ?? opts.rows + let effectiveCwd = restoreInfo?.cwd ?? opts.cwd + let effectiveCols = restoreInfo?.cols ?? opts.cols + let effectiveRows = restoreInfo?.rows ?? opts.rows const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false const isCodexStartupCommand = @@ -184,26 +192,31 @@ export class DaemonPtyAdapter implements IPtyProvider { ? CODEX_SHELL_READY_TIMEOUT_MS : undefined - const result = await this.client.request('createOrAttach', { - sessionId, - cols: effectiveCols, - rows: effectiveRows, - cwd: effectiveCwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or - // PowerShell as a fallback — regardless of which shell the renderer - // asked for in the "+" menu or persisted as the default. Forwarding - // the override makes the daemon path behave the same as the in-process - // LocalPtyProvider. - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - shellReadySupported, - ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}) - }) + const createOrAttach = (historySeed: string | null) => + this.client.request('createOrAttach', { + sessionId, + cols: effectiveCols, + rows: effectiveRows, + cwd: effectiveCwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or + // PowerShell as a fallback — regardless of which shell the renderer + // asked for in the "+" menu or persisted as the default. Forwarding + // the override makes the daemon path behave the same as the in-process + // LocalPtyProvider. + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + shellReadySupported, + ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), + ...(historySeed ? { historySeed } : {}) + }) + + let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + let result = await createOrAttach(scrollback) if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -212,7 +225,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: the daemon RPC returns the shell pid of the backing subprocess. // Surfacing it through PtySpawnResult lets ipc/pty register with the // memory collector without a provider-specific accessor. - const pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null // Why: check sticky cache first — StrictMode double-mounts call spawn // twice. The second call finds an existing daemon session (isNew=false) @@ -238,6 +251,21 @@ export class DaemonPtyAdapter implements IPtyProvider { if (result.isNew && restoreSkippedForLiveSession) { restoreInfo = this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + if (restoreInfo && scrollback) { + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + await this.client.request('kill', { sessionId, immediate: true }) + effectiveCwd = restoreInfo.cwd + effectiveCols = restoreInfo.cols + effectiveRows = restoreInfo.rows + result = await createOrAttach(scrollback) + pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + this.initialCwds.set(sessionId, effectiveCwd) + } + } else if (!result.isNew && result.historySeeded === false) { + restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null } const wasAlreadyManaged = this.activeSessionIds.has(sessionId) @@ -246,7 +274,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Cold restore: daemon created a new session but disk history shows // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. - if (result.isNew && restoreInfo) { + if (restoreInfo && (result.isNew || result.historySeeded === false)) { // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer // (vim/less/htop); normal sessions use the full snapshot + rehydrate. // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) @@ -254,25 +282,33 @@ export class DaemonPtyAdapter implements IPtyProvider { // (no rehydrateSequences — they start with \x1b[?1049h, which the // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as // normal scrollback. An empty snapshot still yields null → no-op. - const isAltScreen = restoreInfo.modes.alternateScreen - const scrollback = isAltScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + const canReanchorHistory = !scrollback || result.historySeeded === true // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before // the next 5s tick, the checkpoint is the only recovery data available. if (this.historyManager) { - this.historyManager.registerWriter(sessionId) - this.sessionsNeedingFullCheckpoint.add(sessionId) - // Why: the revived generation has no valid checkpoint of its own; a - // cooldown inherited from the pre-crash generation (daemon respawn - // within one adapter) must not defer this re-anchor. - this.lastFullCheckpointAt.delete(sessionId) + if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId) + this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint of its own; a + // cooldown inherited from the pre-crash generation (daemon respawn + // within one adapter) must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) + } else { + // Preserve the old recovery files when the new daemon cannot include + // them; a fresh-only checkpoint would make the data loss permanent. + this.historyManager.suspendSession(sessionId) + } } if (scrollback) { const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } this.coldRestoreCache.set(sessionId, coldRestore) - return { id: sessionId, pid, coldRestore } + return { + id: sessionId, + pid, + coldRestore, + ...(!result.isNew ? { isReattach: true } : {}) + } } return { id: sessionId, pid } } @@ -285,6 +321,10 @@ export class DaemonPtyAdapter implements IPtyProvider { rows: effectiveRows }) .catch((err) => console.warn('[history] openSession failed:', sessionId, err)) + } else if (this.historyManager && result.historySeeded === false) { + // Why: the daemon keeps this failure bit with the live session, so a new + // adapter cannot promote its fresh-only snapshot after an app restart. + this.historyManager.suspendSession(sessionId) } else if (this.historyManager) { // Why: on warm reattach after app relaunch, the HistoryManager is a // fresh instance with no writers. registerWriter adds the writer diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 3a585a0896c..7d98babfdee 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -290,6 +290,7 @@ export class DaemonServer { terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, + historySeed: p.historySeed, ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), @@ -332,7 +333,8 @@ export class DaemonServer { isNew: result.isNew, snapshot: result.snapshot, pid: result.pid, - shellState: result.shellState + shellState: result.shellState, + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 0edcdb91ba2..9453488c2d9 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -132,6 +132,13 @@ export class HistoryManager { }) } + suspendSession(sessionId: string): void { + // Why: if a fresh daemon cannot accept recovered scrollback, leaving its + // writer active would let the next checkpoint overwrite the only good copy. + this.writers.delete(sessionId) + this.disabledSessions.delete(sessionId) + } + /** Appends one take batch to the incremental log. Returns 'needs-checkpoint' * when the log is at capacity — the caller must take a full snapshot, which * subsumes the un-appended records (they were already applied to the live diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ad8f14cb068..9ff69f89ab3 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2372,6 +2372,32 @@ describe('createPtySubprocess', () => { } }) + it('does not issue a second Windows ConPTY kill when force follows graceful kill', () => { + const proc = mockPtyProcess(123456) as ReturnType & { + destroy: ReturnType + } + proc.destroy = vi.fn(() => proc.kill()) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('already gone') + }) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.kill() + handle.forceKill() + handle.dispose() + + expect(proc.kill).toHaveBeenCalledOnce() + expect(killSpy).not.toHaveBeenCalled() + expect(proc.destroy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + restorePlatform(origPlatform) + } + }) + it('dispose() on Windows skips destroy after forceKill falls back to node-pty kill()', () => { const proc = mockPtyProcess(123456) as ReturnType & { destroy: ReturnType diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 30eb7b40082..c732a16e1cf 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1094,7 +1094,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // has run, proc.pid refers to a recycled pid. Sending SIGKILL would // terminate an unrelated process. The fd release is handled by // dispose()/destroy(); forceKill is strictly for signalling a live child. - if (dead) { + // Why: Windows node-pty kill already closes ConPTY; retrying it through + // forceKill can double-close the native handle during workspace teardown. + if (dead || (process.platform === 'win32' && nodePtyKillIssued)) { return } try { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index d156aeb4802..0d377c7f3f0 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -65,6 +65,7 @@ export type SessionOptions = { subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number + historySeed?: string scrollback?: number // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — @@ -101,6 +102,7 @@ export class Session { private pendingOutputBytes = 0 private pendingOutputOverflowed = false private pendingOutputSeq = 0 + private readonly _historySeeded: boolean | undefined constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -117,6 +119,10 @@ export class Session { // responder; any daemon reply races ahead via in-process parsing and // clobbers the renderer's answer. See the comment in HeadlessEmulator. }) + // Why: recovery must precede listener registration; shells can emit their + // prompt synchronously as soon as onData subscribes. + this._historySeeded = + opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed) if (opts.shellReadySupported) { this._shellState = 'pending' @@ -141,6 +147,10 @@ export class Session { return this._shellState } + get historySeeded(): boolean | undefined { + return this._historySeeded + } + get exitCode(): number | null { return this._exitCode } diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2a54ce2c19b..8b8968931f5 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -31,6 +31,7 @@ export type CreateOrAttachOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number + historySeed?: string streamClient: { onData: (data: string) => void; onExit: (code: number) => void } } @@ -39,6 +40,7 @@ export type CreateOrAttachResult = { snapshot: TerminalSnapshot | null pid: number | null shellState: ShellReadyState + historySeeded?: boolean attachToken: symbol } @@ -105,6 +107,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, + ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), attachToken: token } } @@ -140,6 +143,7 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, + historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -180,6 +184,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, + ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), attachToken: token } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 6c1c7c281a0..faf0df30996 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,13 +3,11 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or -// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bump when adding daemon wire behavior so same-version old daemons do -// not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 18 +// Why: daemons survive app updates; bump for IPC shape or baked behavior that +// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs. +export const PROTOCOL_VERSION = 19 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -104,6 +102,8 @@ export type CreateOrAttachRequest = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number + /** Recovered ANSI applied before the new subprocess can emit startup output. */ + historySeed?: string } } @@ -313,6 +313,7 @@ export type CreateOrAttachResult = { snapshot: TerminalSnapshot | null pid: number | null shellState: ShellReadyState + historySeeded?: boolean } export type GetSnapshotResult = { From 7d216de4863698585899946e01dfa003bfe402b0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 14:41:09 -0700 Subject: [PATCH 12/95] fix(ports): stop live-port indicator flickering (scan debounce + re-init loop) (#5510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ports): stop live-port indicator flickering on transient scan failures A worktree's plug icon renders only when it has ports. The scanner polls each execution host every 30s and, on a transient failure (SSH/IPC latency), substituted an empty "unavailable" scan — dropping that host's ports for the cycle and blinking the affected row's live-port indicator off, then on again next poll. Debounce per-host failures: reuse the host's last good scan until failures reach a tolerance, so a single dropped poll no longer zeroes the row. A reachable host reporting no ports has no unavailableReason, so a genuine port close still clears immediately. Logic extracted to a pure helper with tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ports): stop port scan re-init loop that flickered live-port icons The scan poll effect depended on the `refresh` callback identity, which gets a fresh reference whenever repos/settings are replaced (frequent in a busy workspace). Each change re-ran the effect, which reset the scan to null and kicked an immediate re-scan — a tight ~600ms loop that blinked the plug icon off/on even though the scanned ports never changed. Drive the poll and advertised-url effects off the latest refresh via a ref and key them on stable value-signatures (enable state, active scan key, host-set signature, runtime kind) instead of the callback identity. Incidental store churn no longer re-inits the scanner; it still polls every 30s, reacts to advertised-url events, and rescans on a real host-set or runtime change. Co-Authored-By: Claude Opus 4.8 (1M context) * Address PR review feedback (#5510) - Prune the per-host failure counter for failed-only hosts that disappear, not just hosts present in lastGood — avoids an unbounded failures map and a stale failure streak if the host reappears - Canonicalize scanTargetsSignature by sorting so a reordered-but-unchanged host set does not re-init the scan and reintroduce flicker Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ports): keep host scans targeted and stable * test(ports): cover scan debounce state --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/rate-limits/service.ts | 10 +- .../native-chat-availability.test.ts | 5 +- .../ports/WorkspacePortScanner.test.tsx | 363 +++++++++++++++++- .../components/ports/WorkspacePortScanner.tsx | 167 ++++++-- .../src/i18n/native-chat-locales.test.ts | 29 +- .../src/lib/launch-agent-in-new-tab.ts | 7 +- .../lib/native-chat-initial-view-mode.test.ts | 6 +- .../lib/workspace-port-scan-debounce.test.ts | 150 ++++++++ .../src/lib/workspace-port-scan-debounce.ts | 44 +++ src/renderer/src/store/slices/ui.ts | 45 +++ src/shared/rate-limit-types.ts | 10 +- 11 files changed, 784 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/lib/workspace-port-scan-debounce.test.ts create mode 100644 src/renderer/src/lib/workspace-port-scan-debounce.ts diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 5c3243c87be..b3800b31807 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1128,7 +1128,15 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' ): ProviderRateLimits { if (!current) { return { diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 8956355fa51..50d19d8821d 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -73,9 +73,8 @@ describe('canToggleNativeChat', () => { experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: 'grok', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - 'runtime-ssh-env-1' - ) + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable('runtime-ssh-env-1') }) ).toBe(true) }) diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx index fa72745c3c3..35bf87d06ad 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx @@ -14,11 +14,14 @@ import { clearRuntimeCompatibilityCache, markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' +import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { useAppStore } from '@/store' import { WorkspacePortScanner } from './WorkspacePortScanner' const localScan = vi.fn() const runtimeEnvironmentCall = vi.fn() +const remoteScanKey = 'environment:env-1:all' +const remoteWorktreeId = 'repo-1::/remote/repo' let container: HTMLDivElement | null = null let root: Root | null = null @@ -27,6 +30,99 @@ const emptyScan: WorkspacePortScanResult = { scannedAt: 1, ports: [] } +const liveScan: WorkspacePortScanResult = { + platform: 'linux', + scannedAt: 1, + ports: [ + { + id: 'tcp:3000', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 3000, + protocol: 'http', + kind: 'workspace', + owner: { + worktreeId: remoteWorktreeId, + repoId: 'repo-1', + displayName: 'main', + path: '/remote/repo', + confidence: 'cwd' + } + } + ] +} + +function addRemoteWorkspace(environmentId: string): void { + const state = useAppStore.getState() + const repoId = `repo-${environmentId}` + const path = `/remote/${environmentId}` + useAppStore.setState({ + repos: [ + ...state.repos, + { + id: repoId, + path, + displayName: environmentId, + connectionId: null, + executionHostId: toRuntimeExecutionHostId(environmentId) + } + ] as never, + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: [ + { + id: `${repoId}::${path}`, + repoId, + path, + displayName: 'main' + } + ] + } as never + }) +} + +function removeRemoteWorkspaces(environmentIds: string[]): void { + const repoIds = new Set(environmentIds.map((environmentId) => `repo-${environmentId}`)) + const state = useAppStore.getState() + useAppStore.setState({ + repos: state.repos.filter((repo) => !repoIds.has(repo.id)) as never, + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).filter(([repoId]) => !repoIds.has(repoId)) + ) as never + }) +} + +function removeAllWorktrees(): void { + const state = useAppStore.getState() + useAppStore.setState({ + worktreesByRepo: Object.fromEntries( + Object.keys(state.worktreesByRepo).map((repoId) => [repoId, []]) + ) as never + }) +} + +function overrideDocumentVisibilityState( + getVisibilityState: () => DocumentVisibilityState +): () => void { + const descriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState') + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: getVisibilityState + }) + return () => { + if (descriptor) { + Object.defineProperty(document, 'visibilityState', descriptor) + } else { + Reflect.deleteProperty(document, 'visibilityState') + } + } +} + +function getPublishedRemoteWorktreePorts() { + return getWorkspacePortsByWorktreeId(useAppStore.getState().workspacePortScan?.result).get( + remoteWorktreeId + ) +} const compatibleStatus = { runtimeId: 'env-1', @@ -40,11 +136,11 @@ async function flushPromises(): Promise { await Promise.resolve() } -function seedRemoteWorkspace(): void { +function seedRemoteWorkspace(environmentId = 'env-1'): void { useAppStore.setState({ settings: { ...getDefaultSettings('/tmp/orca-workspaces'), - activeRuntimeEnvironmentId: 'env-1' + activeRuntimeEnvironmentId: environmentId }, repos: [ { @@ -52,7 +148,7 @@ function seedRemoteWorkspace(): void { path: '/remote/repo', displayName: 'Remote Repo', connectionId: null, - executionHostId: toRuntimeExecutionHostId('env-1') + executionHostId: toRuntimeExecutionHostId(environmentId) } ] as never, worktreesByRepo: { @@ -164,4 +260,265 @@ describe('WorkspacePortScanner', () => { }) expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2) }) + + it('scans a changed execution host immediately instead of applying the prior throttle', async () => { + await act(async () => { + root?.render() + await flushPromises() + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + + markRuntimeEnvironmentCompatible('env-2') + await act(async () => { + seedRemoteWorkspace('env-2') + await flushPromises() + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-2', + method: 'workspacePorts.scan', + params: {}, + timeoutMs: 15_000 + }) + }) + + it('keeps an unchanged host scan when another execution host is added', async () => { + let env1Attempts = 0 + runtimeEnvironmentCall.mockImplementation(({ selector, method }) => { + if (method !== 'workspacePorts.scan') { + return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) + } + if (selector !== 'env-1') { + return Promise.resolve({ ok: true, result: emptyScan }) + } + env1Attempts += 1 + return Promise.resolve({ ok: true, result: liveScan }) + }) + + await act(async () => { + root?.render() + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScansByKey[remoteScanKey]).toBe(liveScan) + + markRuntimeEnvironmentCompatible('env-2') + await act(async () => { + addRemoteWorkspace('env-2') + await flushPromises() + }) + + expect(env1Attempts).toBe(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-2', + method: 'workspacePorts.scan', + params: {}, + timeoutMs: 15_000 + }) + expect(getPublishedRemoteWorktreePorts()).toHaveLength(1) + }) + + it('scans only the added host when the window becomes visible', async () => { + let env1Attempts = 0 + runtimeEnvironmentCall.mockImplementation(({ selector, method }) => { + if (method !== 'workspacePorts.scan') { + return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) + } + if (selector !== 'env-1') { + return Promise.resolve({ ok: true, result: emptyScan }) + } + env1Attempts += 1 + return Promise.resolve({ ok: true, result: liveScan }) + }) + let visibilityState: DocumentVisibilityState = 'visible' + const restoreVisibilityState = overrideDocumentVisibilityState(() => visibilityState) + try { + await act(async () => { + root?.render() + await flushPromises() + }) + expect(env1Attempts).toBe(1) + + visibilityState = 'hidden' + markRuntimeEnvironmentCompatible('env-2') + await act(async () => { + addRemoteWorkspace('env-2') + await flushPromises() + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + + visibilityState = 'visible' + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + }) + + expect(env1Attempts).toBe(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ + selector: 'env-2', + method: 'workspacePorts.scan', + params: {}, + timeoutMs: 15_000 + }) + } finally { + restoreVisibilityState() + } + }) + + it('clears the refreshing state when a retained-host focus change supersedes a poll', async () => { + let env1Calls = 0 + let env2Calls = 0 + let resolveSecondPoll!: (result: { ok: true; result: WorkspacePortScanResult }) => void + const secondPoll = new Promise<{ ok: true; result: WorkspacePortScanResult }>((resolve) => { + resolveSecondPoll = resolve + }) + runtimeEnvironmentCall.mockImplementation(({ selector, method }) => { + if (method !== 'workspacePorts.scan') { + return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) + } + if (selector === 'env-1') { + env1Calls += 1 + return env1Calls === 1 ? Promise.resolve({ ok: true, result: liveScan }) : secondPoll + } + env2Calls += 1 + return env2Calls === 1 ? Promise.resolve({ ok: true, result: emptyScan }) : secondPoll + }) + markRuntimeEnvironmentCompatible('env-2') + addRemoteWorkspace('env-2') + + await act(async () => { + root?.render() + await flushPromises() + }) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(true) + + await act(async () => { + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp/orca-workspaces'), + activeRuntimeEnvironmentId: 'env-2' + } + }) + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + + await act(async () => { + resolveSecondPoll({ ok: true, result: emptyScan }) + await flushPromises() + }) + }) + + it('removes multiple stale host scans in one map publication', async () => { + markRuntimeEnvironmentCompatible('env-2') + markRuntimeEnvironmentCompatible('env-3') + addRemoteWorkspace('env-2') + addRemoteWorkspace('env-3') + + await act(async () => { + root?.render() + await flushPromises() + }) + + let mapNotifications = 0 + const unsubscribe = useAppStore.subscribe((state, previousState) => { + if (state.workspacePortScansByKey !== previousState.workspacePortScansByKey) { + mapNotifications += 1 + } + }) + await act(async () => { + removeRemoteWorkspaces(['env-2', 'env-3']) + await flushPromises() + }) + unsubscribe() + + expect(mapNotifications).toBe(1) + expect(useAppStore.getState().workspacePortScansByKey['environment:env-2:all']).toBeUndefined() + expect(useAppStore.getState().workspacePortScansByKey['environment:env-3:all']).toBeUndefined() + }) + + it('clears ports immediately when the final worktree is removed', async () => { + runtimeEnvironmentCall.mockImplementation(({ method }) => { + if (method === 'workspacePorts.scan') { + return Promise.resolve({ ok: true, result: liveScan }) + } + return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) + }) + + await act(async () => { + root?.render() + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScan).not.toBeNull() + + await act(async () => { + removeAllWorktrees() + await flushPromises() + }) + + expect(useAppStore.getState().workspacePortScan).toBeNull() + expect(useAppStore.getState().workspacePortScansByKey).toEqual({}) + expect(useAppStore.getState().workspacePortScanRefreshing).toBe(false) + }) + + it('keeps the last reachable ports through one failed remote poll', async () => { + let scanAttempts = 0 + runtimeEnvironmentCall.mockImplementation(({ method }) => { + if (method !== 'workspacePorts.scan') { + return Promise.resolve({ ok: false, error: { code: 'method_not_found', message: method } }) + } + scanAttempts += 1 + if (scanAttempts === 1) { + return Promise.resolve({ ok: true, result: liveScan }) + } + return Promise.reject(new Error('temporary runtime failure')) + }) + + await act(async () => { + root?.render() + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScansByKey[remoteScanKey]).toBe(liveScan) + expect(getPublishedRemoteWorktreePorts()).toHaveLength(1) + + let mapNotifications = 0 + let projectionNotifications = 0 + const firstProjection = useAppStore.getState().workspacePortScan + const unsubscribe = useAppStore.subscribe((state, previousState) => { + if (state.workspacePortScansByKey !== previousState.workspacePortScansByKey) { + mapNotifications += 1 + } + if (state.workspacePortScan !== previousState.workspacePortScan) { + projectionNotifications += 1 + } + }) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await flushPromises() + }) + unsubscribe() + expect(useAppStore.getState().workspacePortScansByKey[remoteScanKey]).toBe(liveScan) + expect(getPublishedRemoteWorktreePorts()).toHaveLength(1) + expect(mapNotifications).toBe(0) + expect(useAppStore.getState().workspacePortScan).toBe(firstProjection) + expect(projectionNotifications).toBe(0) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await flushPromises() + }) + expect(useAppStore.getState().workspacePortScansByKey[remoteScanKey]).toMatchObject({ + ports: [], + unavailableReason: 'temporary runtime failure' + }) + expect(getPublishedRemoteWorktreePorts()).toBeUndefined() + }) }) diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index be7fd923044..f2805eb88a7 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -9,6 +9,10 @@ import { workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions' import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { + reconcileTransientPortScanFailures, + type PortScanDebounceState +} from '@/lib/workspace-port-scan-debounce' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' @@ -16,7 +20,10 @@ const WORKSPACE_PORT_SCAN_INTERVAL_MS = 30_000 const WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS = 1_000 type WorkspacePortScannerRefreshOptions = { force?: boolean + targets?: readonly RuntimeClientTarget[] } +// Why: keep live ports through one dropped SSH/IPC scan while reachable empty scans clear now. +const WORKSPACE_PORT_SCAN_FAILURE_THRESHOLD = 2 function makeUnavailableScan(reason: string): WorkspacePortScanResult { return { @@ -32,12 +39,16 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const repos = useAppStore((s) => s.repos) const hasWorktrees = useAppStore(getHasAnyWorktreesFromState) const setWorkspacePortScan = useAppStore((s) => s.setWorkspacePortScan) + const setWorkspacePortScanProjection = useAppStore((s) => s.setWorkspacePortScanProjection) + const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans) const setWorkspacePortScanForKey = useAppStore((s) => s.setWorkspacePortScanForKey) const setWorkspacePortScanRefreshing = useAppStore((s) => s.setWorkspacePortScanRefreshing) const inFlightRef = useRef | null>(null) const generationRef = useRef(0) - const lastRefreshStartedAtRef = useRef(Number.NEGATIVE_INFINITY) + const wasEnabledRef = useRef(false) + const lastRefreshStartedAtByKeyRef = useRef(new Map()) const scanTargetsRef = useRef([]) + const portScanDebounceRef = useRef(new Map()) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) const scanKey = workspacePortScanKeyForTarget(runtimeTarget) @@ -60,25 +71,39 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const refresh = useCallback( (options: WorkspacePortScannerRefreshOptions = {}) => { - const targets = scanTargetsRef.current - if (!hasWorktrees || targets.length === 0) { + const allTargets = scanTargetsRef.current + if (!hasWorktrees || allTargets.length === 0) { + portScanDebounceRef.current.clear() + lastRefreshStartedAtByKeyRef.current.clear() setWorkspacePortScan(null) setWorkspacePortScanRefreshing(false) return Promise.resolve() } + const requestedTargets = options.targets ?? allTargets + if (requestedTargets.length === 0) { + return Promise.resolve() + } if (inFlightRef.current) { return inFlightRef.current } const now = Date.now() - // Why: host/runtime state can rerender this singleton without changing the - // desired poll cadence; remote scans must not restart on every such pass. - if ( - !options.force && - now - lastRefreshStartedAtRef.current < WORKSPACE_PORT_SCAN_INTERVAL_MS - ) { + const targets = options.force + ? requestedTargets + : requestedTargets.filter((target) => { + // Why: each host keeps its own cadence so a newly added runtime cannot restart + // healthy hosts' remote scans. + const key = workspacePortScanKeyForTarget(target) + const lastStartedAt = lastRefreshStartedAtByKeyRef.current.get(key) + return ( + lastStartedAt === undefined || now - lastStartedAt >= WORKSPACE_PORT_SCAN_INTERVAL_MS + ) + }) + if (targets.length === 0) { return Promise.resolve() } - lastRefreshStartedAtRef.current = now + for (const target of targets) { + lastRefreshStartedAtByKeyRef.current.set(workspacePortScanKeyForTarget(target), now) + } const generation = generationRef.current setWorkspacePortScanRefreshing(true) @@ -96,22 +121,45 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): ) .then((results) => { if (generation === generationRef.current) { - const scansByKey = Object.fromEntries(results.map(({ key, result }) => [key, result])) - for (const { key, result } of results) { + const activeTargetKeys = new Set( + allTargets.map((target) => workspacePortScanKeyForTarget(target)) + ) + const reconciled = reconcileTransientPortScanFailures( + results, + useAppStore.getState().workspacePortScansByKey, + portScanDebounceRef.current, + WORKSPACE_PORT_SCAN_FAILURE_THRESHOLD, + activeTargetKeys + ) + const scansByKey = Object.fromEntries( + Object.entries(useAppStore.getState().workspacePortScansByKey).filter(([key]) => + activeTargetKeys.has(key) + ) + ) + let sourceChanged = false + for (const { key, result } of reconciled) { + sourceChanged ||= scansByKey[key] !== result + scansByKey[key] = result setWorkspacePortScanForKey(key, result) } const activeScan = scansByKey[scanKey] const merged = mergeWorkspacePortScans(scansByKey) const projectionKey = - results.length > 1 ? 'all-hosts:all' : activeScan ? scanKey : results[0].key - setWorkspacePortScan( - merged - ? { - key: projectionKey, - result: merged - } - : null - ) + allTargets.length > 1 + ? 'all-hosts:all' + : activeScan + ? scanKey + : workspacePortScanKeyForTarget(allTargets[0]) + if (sourceChanged || useAppStore.getState().workspacePortScan?.key !== projectionKey) { + setWorkspacePortScanProjection( + merged + ? { + key: projectionKey, + result: merged + } + : null + ) + } } }) .finally(() => { @@ -129,6 +177,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): hasWorktrees, scanKey, setWorkspacePortScan, + setWorkspacePortScanProjection, setWorkspacePortScanForKey, setWorkspacePortScanRefreshing ] @@ -136,25 +185,93 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): useEffect(() => { if (!enabled) { + wasEnabledRef.current = false return } + if (!hasWorktrees) { + wasEnabledRef.current = false + portScanDebounceRef.current.clear() + lastRefreshStartedAtByKeyRef.current.clear() + setWorkspacePortScan(null) + setWorkspacePortScanRefreshing(false) + return + } + const wasDisabled = !wasEnabledRef.current + wasEnabledRef.current = true generationRef.current += 1 - setWorkspacePortScan(null) + const targetKeys = new Set( + scanTargetsRef.current.map((target) => workspacePortScanKeyForTarget(target)) + ) + for (const key of lastRefreshStartedAtByKeyRef.current.keys()) { + if (!targetKeys.has(key)) { + lastRefreshStartedAtByKeyRef.current.delete(key) + } + } + const publishedScans = useAppStore.getState().workspacePortScansByKey + const retainedEntries = Object.entries(publishedScans).filter(([key]) => targetKeys.has(key)) + const retainedScans = + retainedEntries.length === Object.keys(publishedScans).length + ? publishedScans + : Object.fromEntries(retainedEntries) + const retainedProjection = mergeWorkspacePortScans(retainedScans) + const retainedProjectionKey = + targetKeys.size > 1 ? 'all-hosts:all' : Object.keys(retainedScans)[0] + // Why: unchanged hosts stay visible while the replacement RPC runs; removed + // hosts and the old synthetic aggregate are excluded immediately. + const nextProjection = + retainedProjection && retainedProjectionKey + ? { key: retainedProjectionKey, result: retainedProjection } + : null + if (retainedScans === publishedScans) { + setWorkspacePortScanProjection(nextProjection) + } else { + replaceWorkspacePortScans(retainedScans, nextProjection) + } + // Why: a scanner resumed after being disabled has no trustworthy cadence; a + // host-set change while it stays enabled only probes the new host. + const targetsToRefresh = wasDisabled + ? scanTargetsRef.current + : scanTargetsRef.current.filter( + (target) => !retainedScans[workspacePortScanKeyForTarget(target)] + ) + // Why: a visible target change should not restart retained hosts; a hidden + // addition still needs its targeted scan when the window becomes visible. + let shouldRefreshOnlyNewTargets = isWindowVisible() || targetsToRefresh.length > 0 // Why: workspace port scans can cross runtime IPC or shell out remotely. // Keep the timer stopped while no UI can display the result; visibility // changes run one immediate refresh on return. const stopVisibleInterval = installWindowVisibilityInterval({ run: () => void refresh(), + runOnVisible: () => { + if (shouldRefreshOnlyNewTargets) { + shouldRefreshOnlyNewTargets = false + if (targetsToRefresh.length > 0) { + void refresh({ force: true, targets: targetsToRefresh }) + } + return + } + void refresh({ force: true }) + }, intervalMs: WORKSPACE_PORT_SCAN_INTERVAL_MS }) return () => { generationRef.current += 1 inFlightRef.current = null + setWorkspacePortScanRefreshing(false) stopVisibleInterval() } - }, [enabled, refresh, scanTargetsSignature, setWorkspacePortScan]) + }, [ + enabled, + hasWorktrees, + refresh, + scanTargetsSignature, + setWorkspacePortScan, + setWorkspacePortScanProjection, + setWorkspacePortScanRefreshing, + replaceWorkspacePortScans + ]) useEffect(() => { if (!enabled) { @@ -182,7 +299,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): if (!isWindowVisible()) { return } - void refresh({ force: true }).finally(() => { + void refresh({ force: true, targets: [runtimeTarget] }).finally(() => { if (disposed || sequence !== eventSequence || !isWindowVisible()) { return } @@ -192,7 +309,7 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): if (disposed || sequence !== eventSequence || !isWindowVisible()) { return } - void refresh({ force: true }) + void refresh({ force: true, targets: [runtimeTarget] }) }, WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS) }) }) diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index faf341c957b..950080dc323 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -10,18 +10,21 @@ const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat const englishSearch = en.auto.components.settings.experimental.search.nativeChat describe('native chat locale copy', () => { - it.each(Object.entries(localizedCatalogs))('%s keeps provider-neutral copy localized', (_code, catalog) => { - const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat - const search = catalog.auto.components.settings.experimental.search.nativeChat - for (const [localized, english] of [ - [setting.description, englishSetting.description], - [setting.copy, englishSetting.copy], - [setting.defaultCopy, englishSetting.defaultCopy], - [search.description, englishSearch.description] - ]) { - expect(localized.trim()).not.toBe('') - expect(localized).not.toBe(english) + it.each(Object.entries(localizedCatalogs))( + '%s keeps provider-neutral copy localized', + (_code, catalog) => { + const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat + const search = catalog.auto.components.settings.experimental.search.nativeChat + for (const [localized, english] of [ + [setting.description, englishSetting.description], + [setting.copy, englishSetting.copy], + [setting.defaultCopy, englishSetting.defaultCopy], + [search.description, englishSearch.description] + ]) { + expect(localized.trim()).not.toBe('') + expect(localized).not.toBe(english) + } + expect(search.grok).toBe('grok') } - expect(search.grok).toBe('grok') - }) + ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 174fdd86ff0..9ceca2e382a 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -266,14 +266,17 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, ...initialAgentTabViewModeProps(store.settings, { agent, promptDelivery: viewModePromptDelivery, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) }) }) store.queueTabStartupCommand(tab.id, { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 10996951048..37adca675f4 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -65,8 +65,7 @@ describe('decideInitialAgentTabViewMode', () => { experimentalNativeChat: true, openAgentTabsInChatByDefault: true, agent: 'grok', - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable(connectionId) + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) }) ).toBe('chat') }) @@ -77,8 +76,7 @@ describe('decideInitialAgentTabViewMode', () => { experimentalNativeChat: true, openAgentTabsInChatByDefault: true, agent: 'grok', - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable('ssh-target-1') + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1') }) ).toBeUndefined() expect( diff --git a/src/renderer/src/lib/workspace-port-scan-debounce.test.ts b/src/renderer/src/lib/workspace-port-scan-debounce.test.ts new file mode 100644 index 00000000000..bacac7ecacf --- /dev/null +++ b/src/renderer/src/lib/workspace-port-scan-debounce.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest' +import { + reconcileTransientPortScanFailures, + type KeyedPortScan, + type PortScanDebounceState +} from './workspace-port-scan-debounce' +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' + +function good(portIds: string[]): WorkspacePortScanResult { + return { + platform: 'unknown', + scannedAt: 1, + ports: portIds.map((id) => ({ + id, + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: Number(id.split(':')[1] ?? 0), + protocol: 'http', + kind: 'external' + })) + } +} + +function unavailable(): WorkspacePortScanResult { + return { platform: 'unknown', scannedAt: 1, ports: [], unavailableReason: 'scan failed' } +} + +const FAILURE_THRESHOLD = 2 + +function createHarness(): { + apply: (results: KeyedPortScan[]) => KeyedPortScan[] + publish: (key: string, result: WorkspacePortScanResult) => void + state: PortScanDebounceState +} { + const state: PortScanDebounceState = new Map() + let publishedScans: Record = {} + return { + apply: (results) => { + const reconciled = reconcileTransientPortScanFailures( + results, + publishedScans, + state, + FAILURE_THRESHOLD + ) + publishedScans = Object.fromEntries(reconciled.map(({ key, result }) => [key, result])) + return reconciled + }, + publish: (key, result) => { + publishedScans = { ...publishedScans, [key]: result } + }, + state + } +} + +describe('reconcileTransientPortScanFailures', () => { + it('keeps the live indicator solid through a single transient failure', () => { + const { apply } = createHarness() + + const first = apply([{ key: 'host:all', result: good(['tcp:3000']) }]) + expect(first[0].result.ports).toHaveLength(1) + + const second = apply([{ key: 'host:all', result: unavailable() }]) + expect(second[0].result.ports).toHaveLength(1) + expect(second[0].result.unavailableReason).toBeUndefined() + }) + + it('drops ports only after failures reach the tolerance', () => { + const { apply } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + apply([{ key: 'h:all', result: unavailable() }]) + + const third = apply([{ key: 'h:all', result: unavailable() }]) + expect(third[0].result.ports).toHaveLength(0) + expect(third[0].result.unavailableReason).toBe('scan failed') + }) + + it('clears immediately when a reachable host reports zero ports', () => { + const { apply } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + + const next = apply([{ key: 'h:all', result: good([]) }]) + expect(next[0].result.ports).toHaveLength(0) + expect(next[0].result.unavailableReason).toBeUndefined() + }) + + it('resets the failure streak after a successful scan', () => { + const { apply } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + apply([{ key: 'h:all', result: unavailable() }]) + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + + const afterRecovery = apply([{ key: 'h:all', result: unavailable() }]) + expect(afterRecovery[0].result.ports).toHaveLength(1) + }) + + it('isolates failures per host so a stable host stays solid', () => { + const { apply } = createHarness() + apply([ + { key: 'local:all', result: good(['tcp:3000']) }, + { key: 'remote:all', result: good(['tcp:8080']) } + ]) + + const next = apply([ + { key: 'local:all', result: good(['tcp:3000']) }, + { key: 'remote:all', result: unavailable() } + ]) + expect(next.find((r) => r.key === 'local:all')?.result.ports).toHaveLength(1) + expect(next.find((r) => r.key === 'remote:all')?.result.ports).toHaveLength(1) + }) + + it('prunes state for hosts that disappear', () => { + const { apply, state } = createHarness() + apply([{ key: 'gone:all', result: good(['tcp:3000']) }]) + apply([{ key: 'other:all', result: good(['tcp:4000']) }]) + expect(state.has('gone:all')).toBe(false) + expect(state.has('other:all')).toBe(true) + }) + + it('prunes the failure counter for a failed-only host that disappears', () => { + const { apply, state } = createHarness() + apply([{ key: 'flaky:all', result: unavailable() }]) + expect(state.has('flaky:all')).toBe(true) + + apply([{ key: 'other:all', result: good(['tcp:4000']) }]) + expect(state.has('flaky:all')).toBe(false) + }) + + it('uses a newer manual result instead of resurrecting stale ports', () => { + const { apply, publish } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + publish('h:all', good([])) + + const next = apply([{ key: 'h:all', result: unavailable() }]) + + expect(next[0].result.ports).toHaveLength(0) + expect(next[0].result.unavailableReason).toBeUndefined() + }) + + it('starts a fresh failure streak after a newer manual result', () => { + const { apply, publish } = createHarness() + apply([{ key: 'h:all', result: good(['tcp:3000']) }]) + apply([{ key: 'h:all', result: unavailable() }]) + const manualResult = good([]) + publish('h:all', manualResult) + + const next = apply([{ key: 'h:all', result: unavailable() }]) + + expect(next[0].result).toBe(manualResult) + }) +}) diff --git a/src/renderer/src/lib/workspace-port-scan-debounce.ts b/src/renderer/src/lib/workspace-port-scan-debounce.ts new file mode 100644 index 00000000000..a6281f0ed9f --- /dev/null +++ b/src/renderer/src/lib/workspace-port-scan-debounce.ts @@ -0,0 +1,44 @@ +import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' + +export type KeyedPortScan = { key: string; result: WorkspacePortScanResult } + +type PortScanDebounceEntry = { + consecutiveFailures: number + publishedResult: WorkspacePortScanResult +} + +export type PortScanDebounceState = Map + +/** Keeps each host's last reachable scan through brief failures; reachable empty scans apply now. */ +export function reconcileTransientPortScanFailures( + results: KeyedPortScan[], + publishedScans: Record, + state: PortScanDebounceState, + failureThreshold: number, + activeKeys: ReadonlySet = new Set(results.map(({ key }) => key)) +): KeyedPortScan[] { + for (const key of state.keys()) { + if (!activeKeys.has(key)) { + state.delete(key) + } + } + + return results.map(({ key, result }) => { + const previous = state.get(key) + const publishedResult = publishedScans[key] + // A different object was published by another refresh path, so it breaks this poller's streak. + const previousFailures = + previous && previous.publishedResult === publishedResult ? previous.consecutiveFailures : 0 + if (!result.unavailableReason) { + state.set(key, { consecutiveFailures: 0, publishedResult: result }) + return { key, result } + } + const failures = previousFailures + 1 + const nextResult = + failures < failureThreshold && publishedResult && !publishedResult.unavailableReason + ? publishedResult + : result + state.set(key, { consecutiveFailures: failures, publishedResult: nextResult }) + return { key, result: nextResult } + }) +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index f5d2432a900..205d31878cc 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -873,6 +873,13 @@ export type UISlice = { workspacePortScansByKey: Record workspacePortScanRefreshing: boolean setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void + setWorkspacePortScanProjection: ( + scan: { key: string; result: WorkspacePortScanResult } | null + ) => void + replaceWorkspacePortScans: ( + scansByKey: Record, + projection: { key: string; result: WorkspacePortScanResult } | null + ) => void setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void setWorkspacePortScanRefreshing: (refreshing: boolean) => void /** Whether the experimental pet overlay is currently visible. Persisted @@ -2111,15 +2118,53 @@ export const createUISlice: StateCreator = (set, get) setWorkspacePortScan: (scan) => set((state) => { if (!scan) { + if (!state.workspacePortScan && Object.keys(state.workspacePortScansByKey).length === 0) { + return state + } return { workspacePortScan: null, workspacePortScansByKey: {} } } + if ( + state.workspacePortScan?.key === scan.key && + state.workspacePortScan.result === scan.result && + state.workspacePortScansByKey[scan.key] === scan.result + ) { + return state + } return { workspacePortScan: scan, workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } } }), + // Why: target changes rebuild the aggregate without republishing or clearing per-host scans. + setWorkspacePortScanProjection: (scan) => + set((state) => { + if ( + state.workspacePortScan?.key === scan?.key && + state.workspacePortScan?.result === scan?.result + ) { + return state + } + return { workspacePortScan: scan } + }), + // Why: host-set changes must remove stale per-host scans in one store update so a + // large disconnected host set cannot fan out map notifications to every subscriber. + replaceWorkspacePortScans: (scansByKey, projection) => + set((state) => { + if ( + state.workspacePortScansByKey === scansByKey && + state.workspacePortScan?.key === projection?.key && + state.workspacePortScan?.result === projection?.result + ) { + return state + } + return { workspacePortScansByKey: scansByKey, workspacePortScan: projection } + }), setWorkspacePortScanForKey: (key, result) => set((state) => { + const currentResult = state.workspacePortScansByKey[key] + if (currentResult === result || (!result && !currentResult)) { + return state + } const nextScansByKey = { ...state.workspacePortScansByKey } if (result) { nextScansByKey[key] = result diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5470e851506..65c09aaffae 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,15 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ From 7a5632584a840fa74130658819491cf9bb44e6c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:44:38 -0700 Subject: [PATCH 13/95] Fix WebGL glyph atlas wipe no-op, retry-consume, and page-overflow in addon patch (#8150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextureAtlas.clearTexture() guarded on pages[0].currentRow being at the origin — but merged pages are never written through currentRow, so once a page merge lands a merged page at index 0, every atlas wipe for the rest of the session silently no-ops: no content clear, no glyph-cache clear, no clear-model generation bump. Recoveries degrade to placebo, the glyph cache grows unboundedly, and the session drifts into a merge-heavy state where stale vertex data paints the tiny-glyph garble seen after unfocus-then-paste into Claude Code. Fixes in @xterm/addon-webgl patch (lib bundles + readable src): - clearTexture(): guard on any-page-has-glyphs; restore _activePages after wiping (merges permanently shrink the active set) - AtlasPage.clear(): reset _glyphs/_usedPixels so wiped pages stop distorting merge-candidate selection - renderRows merge-retry loop: check the retry budget before beginFrame() so tripping the limit cannot consume the clear-model notification and strand a stale model - GlyphRenderer.render(): clamp texture binds to the sampler budget so pages overflowing maxAtlasPages cannot TypeError and kill rendering Verified: two-terminal shared-atlas harness (storm scenario proves the no-op and its fix; 6-seed adversarial fuzzer clean), 405 unit tests in pane-manager/atlas-recovery suites, terminal-rendering golden e2e. --- .../@xterm__addon-webgl@0.20.0-beta.286.patch | 89 +++++++++++++++++-- pnpm-lock.yaml | 6 +- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch b/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch index 7ea9975151c..5276bb1d813 100644 --- a/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch +++ b/config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch @@ -1,14 +1,14 @@ diff --git a/lib/addon-webgl.js b/lib/addon-webgl.js -index bd9997910ca3490aa85a140ea22913c6faacacb4..25d481fb52b8302f850fe7158ec50e5ffabf18c6 100644 +index bd9997910ca3490aa85a140ea22913c6faacacb4..074eb0c7ac6a20f81f6d349c63b114e7defab73a 100644 --- a/lib/addon-webgl.js +++ b/lib/addon-webgl.js @@ -1,2 +1,2 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=h(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=h(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{constructor(t,e){this._runner=t,this.priority=e,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function h(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class T extends s.IntervalTimer{constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=T},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.MicrotaskTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(t){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,t())}))}},e.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,h,T;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=T.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,h=e.rgba>>8&255,T=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=T.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=T.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1],10),s=parseInt(L[2],10),r=parseInt(L[3],10),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(h||(e.rgb=h={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,h=e>>8&255,T=t>>24&255,l=t>>16&255,d=t>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=h.relativeLuminance(t>>8),a=h.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,h.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,h.relativeLuminance(o>>8));if(Ld(r,h.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(T||(e.rgba=T={}))},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,a.None=Object.freeze({dispose(){}}),e.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{constructor(){this._data=new i}set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},95(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.isChromeOS=e.isLinux=e.isWindows=e.isMac=e.isSafari=e.isLegacyEdge=e.isChrome=e.isFirefox=e.isNode=void 0,e.getZoomFactor=function(t){return 1},e.getSafariVersion=function(){if(!e.isSafari)return 0;const t=i.match(/Version\/(\d+)/);return null===t||t.length<2?0:parseInt(t[1],10)},e.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=e.isNode?"node":navigator.userAgent,s=e.isNode?"node":navigator.platform;e.isFirefox=i.includes("Firefox"),e.isChrome=i.includes("Chrome"),e.isLegacyEdge=i.includes("Edge"),e.isSafari=/^((?!chrome|android).)*safari/i.test(i),e.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),e.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),e.isLinux=s.indexOf("Linux")>=0,e.isChromeOS=/\bCrOS\b/.test(i)},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{constructor(t){this._tasks=[],this._i=0,this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ii)return r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=0,this._urlId=0,this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},721(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellData=void 0;const s=i(348),r=i(564),a=i(917);class o extends a.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new a.ExtendedAttrs,this.combinedData=""}static fromCharData(t){const e=new o;return e.setFromCharData(t),e}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},348(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Utf8ToUtf32=e.StringToUtf32=void 0,e.stringFromCodePoint=function(t){return t>65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n,L=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=this.interim[++o])&&o<4;)r<<=6,r|=63&a;const n=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,T=n-o;for(;h=i)return 0;if(a=t[h++],128!=(192&a)){h--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===n?r<128?h--:e[L++]=r:3===n?r<2048||r>=55296&&r<=57343||65279===r||(e[L++]=r):r<65536||r>1114111||(e[L++]=r)),this.interim.fill(0)}const T=i-4;let l=h;for(;l=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(n=(31&s)<<6|63&r,n<128){l--;continue}e[L++]=n}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(n=(15&s)<<12|(63&r)<<6|63&a,n<2048||n>=55296&&n<=57343||65279===n)continue;e[L++]=n}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,L;if(o=t[l++],128!=(192&o)){l--;continue}if(n=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,n<65536||n>1114111)continue;e[L++]=n}}return L}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,h=0,T=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a,this.result={fg:0,bg:0,ext:0}}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,h=0,L=0,l=!1,T=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:h=(16777215&this.result.fg)<<8|255;break;default:h=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:h=(16777215&this.result.bg)<<8|255}h=r.rgba.blend(h,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else h=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,T=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}T=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),l&&(h=d?-16777216&t.bg&-134217729|h|50331648:-16777216&t.bg|h|50331648),T&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!T&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,T=!0),!l&&T&&(h=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?h:this.result.bg,this.result.fg=T?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},670(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.acquireTextureAtlas=function(t,e,i,o,n,L,h,T,l,d=!0){const c=(0,r.generateConfig)(o,n,L,h,e,i,T,l,d);for(let e=0;e=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},697(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.observeDevicePixelDimensions=function(t,e,i){let r=new e.ResizeObserver(e=>{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426),o=i(719),n=i(959);let L,h=0,T=0,l=0;class d extends a.Disposable{constructor(t,e,i,r,L){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r,this._logService=L,this._activeBuffer=0,this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};const h=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(h,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;ih.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_texture"));const T=this._vertexArrayObject=h.createVertexArray();this._register((0,a.toDisposable)(()=>h.deleteVertexArray(T))),h.bindVertexArray(T);const l=new Float32Array([0,0,1,0,0,1,1,1]),d=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(d))),h.bindBuffer(h.ARRAY_BUFFER,d),h.bufferData(h.ARRAY_BUFFER,l,h.STATIC_DRAW),h.enableVertexAttribArray(0),h.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const c=new Uint8Array([0,1,2,3]),_=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(_))),h.bindBuffer(h.ELEMENT_ARRAY_BUFFER,_),h.bufferData(h.ELEMENT_ARRAY_BUFFER,c,h.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(h.createBuffer()),this._register((0,a.toDisposable)(()=>h.deleteBuffer(this._attributesBuffer))),h.bindBuffer(h.ARRAY_BUFFER,this._attributesBuffer),h.enableVertexAttribArray(2),h.vertexAttribPointer(2,2,h.FLOAT,!1,44,0),h.vertexAttribDivisor(2,1),h.enableVertexAttribArray(3),h.vertexAttribPointer(3,2,h.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(3,1),h.enableVertexAttribArray(4),h.vertexAttribPointer(4,1,h.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(4,1),h.enableVertexAttribArray(5),h.vertexAttribPointer(5,2,h.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(5,1),h.enableVertexAttribArray(6),h.vertexAttribPointer(6,2,h.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(6,1),h.enableVertexAttribArray(1),h.vertexAttribPointer(1,2,h.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(1,1),h.useProgram(this._program);const Q=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;th.deleteTexture(e.texture))),h.activeTexture(h.TEXTURE0+t),h.bindTexture(h.TEXTURE_2D,e.texture),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_S,h.CLAMP_TO_EDGE),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_T,h.CLAMP_TO_EDGE),h.texImage2D(h.TEXTURE_2D,0,h.RGBA,1,1,0,h.RGBA,h.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}h.enable(h.BLEND),h.blendFunc(h.SRC_ALPHA,h.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return!this._atlas||this._atlas.beginFrame()}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){h=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),T=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>T?(l=L.offset.x-T,t[h]=-(L.offset.x-l)+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[h+8]=L.sizeClipSpace.y):(t[h]=-L.offset.x+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=L.size.x/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x,t[h+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[h+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,h,h+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;iT.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(T.getUniformLocation(this._program,"u_projection"));const l=this._vertexArrayObject=T.createVertexArray();this._register((0,s.toDisposable)(()=>T.deleteVertexArray(l))),T.bindVertexArray(l);const d=new Float32Array([0,0,1,0,0,1,1,1]),c=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(c))),T.bindBuffer(T.ARRAY_BUFFER,c),T.bufferData(T.ARRAY_BUFFER,d,T.STATIC_DRAW),T.enableVertexAttribArray(3),T.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const _=new Uint8Array([0,1,2,3]),Q=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(Q))),T.bindBuffer(T.ELEMENT_ARRAY_BUFFER,Q),T.bufferData(T.ELEMENT_ARRAY_BUFFER,_,T.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(T.createBuffer()),this._register((0,s.toDisposable)(()=>T.deleteBuffer(this._attributesBuffer))),T.bindBuffer(T.ARRAY_BUFFER,this._attributesBuffer),T.enableVertexAttribArray(0),T.vertexAttribPointer(0,2,T.FLOAT,!1,o,0),T.vertexAttribDivisor(0,1),T.enableVertexAttribArray(1),T.vertexAttribPointer(1,2,T.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(1,1),T.enableVertexAttribArray(2),T.vertexAttribPointer(2,4,T.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(2,1),this._updateCachedColors(L.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,h,T,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,h,T,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,h){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=h}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),h=i(917),T=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{get pages(){return this._pages}constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._didWarmUp=!1,this._cacheMap=new n.FourKeyMap,this._cacheMapCombined=new n.FourKeyMap,this._pages=[],this._activePages=[],this._workBoundingBox={top:0,left:0,bottom:0,right:0},this._workAttributeData=new h.AttributeData,this._textureSize=512,this._onAddTextureAtlasCanvas=new l.Emitter,this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=new l.Emitter,this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._requestClearModel=!1,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT)){const t=this._drawToCache(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,t)}})}beginFrame(){const t=this._requestClearModel;return this._requestClearModel=!1,t}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t-e),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version=++Q.nextVersion;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=h.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,T,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,T,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=h.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),T&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,h,T){if(1===this._config.minimumContrastRatio||T)return;const l=this._getContrastCache(h),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(h?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const T="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(T.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h+_,s),this._tmpCtx.lineTo(T,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(h+_,s),this._tmpCtx.moveTo(h+_+t,s),this._tmpCtx.lineTo(T,s)),o=(0,a.computeNextVariantOffset)(T-h,t,o);break;case 5:const Q=.6,u=.3,p=T-h,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s);break;default:this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==T){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(T);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(T,S,S+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight),"_"===T&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,D,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,S);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version=++Q.nextVersion,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,h=i.rgba>>>8&255,T=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-h))/12);let l=!0;for(let e=0;ethis._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,d,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(d.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new T.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{this._logService.debug("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e);let i=!1,s=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&s++<32;)i=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);i&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,T,l,d,_,Q,u,p,g,M,f,C,m=this._workCell;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp;const t=i.buffer.lines.get(a);if(!t){for(this._model.lineLengths[r]=0,f=0;f=T,d=f,n.length>0&&f===n[0][0]&&l){_=n.shift();const t=this._model.selection.isCellSelected(this._terminal,_[0],a);for(M=_[0]+1;M<_[1];M++)l&&=t===this._model.selection.isCellSelected(this._terminal,M,a);l&&=!Q||E<_[0]||E>=_[1],l?(L=!0,m=new y(m,o.translateToString(!0,_[0],_[1]),_[1]-_[0]),d=_[1]-1):T=_[1]}if(u=m.getChars(),p=m.getCode(),M=4*(r*i.cols+f),!e&&m.isBlink()&&(e=!0),this._cellColorResolver.resolve(m,f,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(f===E&&(this._model.cursor={x:E,y:b,width:m.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+m.getWidth()-1),f>=E&&f<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&m.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),p!==h.NULL_CELL_CODE&&(this._model.lineLengths[r]=f+1),(this._model.cells[M]!==p||this._model.cells[M+1]!==this._cellColorResolver.result.bg||this._model.cells[M+2]!==this._cellColorResolver.result.fg||this._model.cells[M+3]!==this._cellColorResolver.result.ext)&&(A=!0,u.length>1&&(p|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[M]=p,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext,g=m.getWidth(),this._glyphRenderer.value.updateCell(f,r,p,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,u,g,s),L)){for(m=this._workCell,f++;f<=d;f++)C=4*(r*i.cols+f),this._nullModelCell(f,r,C,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);f--}}this._setRowBlinkState(r,e)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,e,i,s,r,a){this._glyphRenderer.value.updateCell(t,e,h.NULL_CELL_CODE,s,r,a,h.NULL_CELL_CHAR,0,0),this._model.cells[i]=h.NULL_CELL_CODE,this._model.cells[i+1]=s,this._model.cells[i+2]=r,this._model.cells[i+3]=a}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{constructor(t,e,i){super(),this.content=0,this.combinedData="",this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i,a){const o=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e,a))),t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i,a))),t.linkProgram(o),t.getProgramParameter(o,t.LINK_STATUS))return o;a.error(t.getProgramInfoLog(o)),t.deleteProgram(o)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function r(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(i||(i={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":i.LINE_H,"":i.LINE_V,"":i.FADE_RIGHT,"":i.FADE_LEFT,"":i.FADE_DOWN,"":i.FADE_UP,"":i.CURVE_DOWN_RIGHT,"":i.CURVE_DOWN_LEFT,"":i.CURVE_UP_RIGHT,"":i.CURVE_UP_LEFT,"":[i.LINE_V,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT],"":[i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_UP_RIGHT],"":[i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE],"":i.NODE_STROKE,"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"🬀":s(1),"🬁":s(2),"🬂":s(3),"🬃":s(4),"🬄":s(5),"🬅":s(6),"🬆":s(7),"🬇":s(8),"🬈":s(9),"🬉":s(10),"🬊":s(11),"🬋":s(12),"🬌":s(13),"🬍":s(14),"🬎":s(15),"🬏":s(16),"🬐":s(17),"🬑":s(18),"🬒":s(19),"🬓":s(20),"🬔":s(22),"🬕":s(23),"🬖":s(24),"🬗":s(25),"🬘":s(26),"🬙":s(27),"🬚":s(28),"🬛":s(29),"🬜":s(30),"🬝":s(31),"🬞":s(32),"🬟":s(33),"🬠":s(34),"🬡":s(35),"🬢":s(36),"🬣":s(37),"🬤":s(38),"🬥":s(39),"🬦":s(40),"🬧":s(41),"🬨":s(43),"🬩":s(44),"🬪":s(45),"🬫":s(46),"🬬":s(47),"🬭":s(48),"🬮":s(49),"🬯":s(50),"🬰":s(51),"🬱":s(52),"🬲":s(53),"🬳":s(54),"🬴":s(55),"🬵":s(56),"🬶":s(57),"🬷":s(58),"🬸":s(59),"🬹":s(60),"🬺":s(61),"🬻":s(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:r(126),type:0},scaleType:1},"🯱":{type:5,data:{d:r(48),type:0},scaleType:1},"🯲":{type:5,data:{d:r(109),type:0},scaleType:1},"🯳":{type:5,data:{d:r(121),type:0},scaleType:1},"🯴":{type:5,data:{d:r(51),type:0},scaleType:1},"🯵":{type:5,data:{d:r(91),type:0},scaleType:1},"🯶":{type:5,data:{d:r(95),type:0},scaleType:1},"🯷":{type:5,data:{d:r(114),type:0},scaleType:1},"🯸":{type:5,data:{d:r(127),type:0},scaleType:1},"🯹":{type:5,data:{d:r(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,h,T,l,d,c,_=0){const Q=r.customGlyphDefinitions[e];if(Q){const e=Array.isArray(Q)?Q:[Q];for(const r of e)a(t,r,i,s,o,n,L,h,T,l,d,c,_);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,h,d,c,_,Q,u,p,g=0){let M=a,y=h,v=i,f=r;switch(1===e.scaleType&&(M=d,y=c,v=i+(a-d)/2,f=r+(h-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,h=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,h):"L"===e&&t.lineTo(L,h)}t.clip()}(t,e.clipPath,v,f,M,y)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(T.slice(7,9),16)||1;else{if(!T.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${T}" when drawing pattern glyph`);[L,d,c,_]=T.substring(5,T.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,v,f,M,y,g);break;case 2:!function(t,e,i,s,r,a,o,n,L){let h;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),h="function"==typeof e?e(.15,.15/a*r):e;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of h.split(" ")){const L=e[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const h=T[L];if(!h){n.error(`Could not find drawing instructions for "${L}"`);continue}const c=e.substring(1).split(",");c[0]&&c[1]&&(h(t,l(c,r,a,i,s,!0,o,0,0,!1),d),d.lastCommand=L)}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,v,f,M,y,Q,u,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const h="string"==typeof e?e:e(0,0);t.beginPath();let T=0,l=0,d=0,c=0,_="";for(const e of h.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(T,e),l=e,d=T,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),T=e,d=T,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,h=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3],10),c=parseInt(L[4],10),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,T,l,e,o,h,d,c,_,Q),T=_,l=Q;continue}const h=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("L"===o)t.lineTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("Q"===o)t.quadraticCurveTo(h[0],h[1],h[2],h[3]),d=h[0],c=h[1],T=h[2],l=h[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*T-d,i=2*l-c):(e=T,i=l),t.quadraticCurveTo(e,i,h[0],h[1]),d=e,c=i,T=h[0],l=h[1]}else"C"===o&&(t.bezierCurveTo(h[0],h[1],h[2],h[3],h[4],h[5]),d=h[2],c=h[3],T=h[4],l=h[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,v,f,M,y,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,v,f,M,y,Q,p);break;case 5:!function(t,e,i,s,r,a,o,n,L){const h=new Path2D;h.rect(i,s,r,a),t.clip(h),t.beginPath();const d=o/12;t.lineWidth=n*d;const c={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const h=o[0];if("Z"===h){t.closePath(),c.lastCommand=h;continue}const _=T[h];if(!_){L.error(`Could not find drawing instructions for "${h}"`);continue}const Q=o.substring(1).split(",");Q[0]&&Q[1]&&(_(t,l(Q,r,a,i,s,!1,n,(e.leftPadding??0)*(d/2),(e.rightPadding??0)*(d/2)),c),c.lastCommand=h)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,v,f,M,y,_,Q,u);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,h=.8*a/8,T=Math.min(n,h);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=T*C-l*m+(e+L)/2,x=l*C+T*m+(i+h)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const D=A+Z;t.ellipse(w,x,s,r,a,A,D,0===n)}const L=new Map;function h(t,e,i=0){return Math.max(Math.min(t,e),i)}const T={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,T=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;t{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("WebGL2 is only supported on Safari 16 and above")}super(),this._onChangeTextureAtlas=this._register(new o.Emitter),this.onChangeTextureAtlas=this._onChangeTextureAtlas.event,this._onAddTextureAtlasCanvas=this._register(new o.Emitter),this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=this._register(new o.Emitter),this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._onContextLoss=this._register(new o.Emitter),this.onContextLoss=this._onContextLoss.event,this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,h=n._characterJoinerService,T=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._logService,_=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,h,T,l,s,d,c,r,_,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); -+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=h(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=h(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{constructor(t,e){this._runner=t,this.priority=e,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function h(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class T extends s.IntervalTimer{constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=T},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.MicrotaskTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(t){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,t())}))}},e.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,h,T;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=T.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,h=e.rgba>>8&255,T=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=T.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=T.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1],10),s=parseInt(L[2],10),r=parseInt(L[3],10),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(h||(e.rgb=h={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,h=e>>8&255,T=t>>24&255,l=t>>16&255,d=t>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=h.relativeLuminance(t>>8),a=h.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,h.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,h.relativeLuminance(o>>8));if(Ld(r,h.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(T||(e.rgba=T={}))},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,a.None=Object.freeze({dispose(){}}),e.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{constructor(){this._data=new i}set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},95(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.isChromeOS=e.isLinux=e.isWindows=e.isMac=e.isSafari=e.isLegacyEdge=e.isChrome=e.isFirefox=e.isNode=void 0,e.getZoomFactor=function(t){return 1},e.getSafariVersion=function(){if(!e.isSafari)return 0;const t=i.match(/Version\/(\d+)/);return null===t||t.length<2?0:parseInt(t[1],10)},e.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=e.isNode?"node":navigator.userAgent,s=e.isNode?"node":navigator.platform;e.isFirefox=i.includes("Firefox"),e.isChrome=i.includes("Chrome"),e.isLegacyEdge=i.includes("Edge"),e.isSafari=/^((?!chrome|android).)*safari/i.test(i),e.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),e.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),e.isLinux=s.indexOf("Linux")>=0,e.isChromeOS=/\bCrOS\b/.test(i)},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{constructor(t){this._tasks=[],this._i=0,this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ii)return r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=0,this._urlId=0,this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},721(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellData=void 0;const s=i(348),r=i(564),a=i(917);class o extends a.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new a.ExtendedAttrs,this.combinedData=""}static fromCharData(t){const e=new o;return e.setFromCharData(t),e}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},348(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Utf8ToUtf32=e.StringToUtf32=void 0,e.stringFromCodePoint=function(t){return t>65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n,L=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=this.interim[++o])&&o<4;)r<<=6,r|=63&a;const n=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,T=n-o;for(;h=i)return 0;if(a=t[h++],128!=(192&a)){h--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===n?r<128?h--:e[L++]=r:3===n?r<2048||r>=55296&&r<=57343||65279===r||(e[L++]=r):r<65536||r>1114111||(e[L++]=r)),this.interim.fill(0)}const T=i-4;let l=h;for(;l=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(n=(31&s)<<6|63&r,n<128){l--;continue}e[L++]=n}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(n=(15&s)<<12|(63&r)<<6|63&a,n<2048||n>=55296&&n<=57343||65279===n)continue;e[L++]=n}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,L;if(o=t[l++],128!=(192&o)){l--;continue}if(n=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,n<65536||n>1114111)continue;e[L++]=n}}return L}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,h=0,T=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a,this.result={fg:0,bg:0,ext:0}}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,h=0,L=0,l=!1,T=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:h=(16777215&this.result.fg)<<8|255;break;default:h=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:h=(16777215&this.result.bg)<<8|255}h=r.rgba.blend(h,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else h=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,T=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}T=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),l&&(h=d?-16777216&t.bg&-134217729|h|50331648:-16777216&t.bg|h|50331648),T&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!T&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,T=!0),!l&&T&&(h=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?h:this.result.bg,this.result.fg=T?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},670(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.acquireTextureAtlas=function(t,e,i,o,n,L,h,T,l,d=!0){const c=(0,r.generateConfig)(o,n,L,h,e,i,T,l,d);for(let e=0;e=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},697(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.observeDevicePixelDimensions=function(t,e,i){let r=new e.ResizeObserver(e=>{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426),o=i(719),n=i(959);let L,h=0,T=0,l=0;class d extends a.Disposable{constructor(t,e,i,r,L){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r,this._logService=L,this._activeBuffer=0,this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};const h=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(h,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;ih.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_texture"));const T=this._vertexArrayObject=h.createVertexArray();this._register((0,a.toDisposable)(()=>h.deleteVertexArray(T))),h.bindVertexArray(T);const l=new Float32Array([0,0,1,0,0,1,1,1]),d=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(d))),h.bindBuffer(h.ARRAY_BUFFER,d),h.bufferData(h.ARRAY_BUFFER,l,h.STATIC_DRAW),h.enableVertexAttribArray(0),h.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const c=new Uint8Array([0,1,2,3]),_=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(_))),h.bindBuffer(h.ELEMENT_ARRAY_BUFFER,_),h.bufferData(h.ELEMENT_ARRAY_BUFFER,c,h.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(h.createBuffer()),this._register((0,a.toDisposable)(()=>h.deleteBuffer(this._attributesBuffer))),h.bindBuffer(h.ARRAY_BUFFER,this._attributesBuffer),h.enableVertexAttribArray(2),h.vertexAttribPointer(2,2,h.FLOAT,!1,44,0),h.vertexAttribDivisor(2,1),h.enableVertexAttribArray(3),h.vertexAttribPointer(3,2,h.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(3,1),h.enableVertexAttribArray(4),h.vertexAttribPointer(4,1,h.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(4,1),h.enableVertexAttribArray(5),h.vertexAttribPointer(5,2,h.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(5,1),h.enableVertexAttribArray(6),h.vertexAttribPointer(6,2,h.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(6,1),h.enableVertexAttribArray(1),h.vertexAttribPointer(1,2,h.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(1,1),h.useProgram(this._program);const Q=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;th.deleteTexture(e.texture))),h.activeTexture(h.TEXTURE0+t),h.bindTexture(h.TEXTURE_2D,e.texture),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_S,h.CLAMP_TO_EDGE),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_T,h.CLAMP_TO_EDGE),h.texImage2D(h.TEXTURE_2D,0,h.RGBA,1,1,0,h.RGBA,h.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}h.enable(h.BLEND),h.blendFunc(h.SRC_ALPHA,h.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){if(!this._atlas)return!0;const _g=this._atlas._clearModelGeneration||0;return this._lastSeenClearModelGeneration!==_g&&(this._lastSeenClearModelGeneration=_g,!0)}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){h=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),T=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>T?(l=L.offset.x-T,t[h]=-(L.offset.x-l)+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[h+8]=L.sizeClipSpace.y):(t[h]=-L.offset.x+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=L.size.x/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x,t[h+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[h+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,h,h+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;iT.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(T.getUniformLocation(this._program,"u_projection"));const l=this._vertexArrayObject=T.createVertexArray();this._register((0,s.toDisposable)(()=>T.deleteVertexArray(l))),T.bindVertexArray(l);const d=new Float32Array([0,0,1,0,0,1,1,1]),c=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(c))),T.bindBuffer(T.ARRAY_BUFFER,c),T.bufferData(T.ARRAY_BUFFER,d,T.STATIC_DRAW),T.enableVertexAttribArray(3),T.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const _=new Uint8Array([0,1,2,3]),Q=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(Q))),T.bindBuffer(T.ELEMENT_ARRAY_BUFFER,Q),T.bufferData(T.ELEMENT_ARRAY_BUFFER,_,T.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(T.createBuffer()),this._register((0,s.toDisposable)(()=>T.deleteBuffer(this._attributesBuffer))),T.bindBuffer(T.ARRAY_BUFFER,this._attributesBuffer),T.enableVertexAttribArray(0),T.vertexAttribPointer(0,2,T.FLOAT,!1,o,0),T.vertexAttribDivisor(0,1),T.enableVertexAttribArray(1),T.vertexAttribPointer(1,2,T.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(1,1),T.enableVertexAttribArray(2),T.vertexAttribPointer(2,4,T.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(2,1),this._updateCachedColors(L.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,h,T,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,h,T,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,h){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=h}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),h=i(917),T=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{get pages(){return this._pages}constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._didWarmUp=!1,this._cacheMap=new n.FourKeyMap,this._cacheMapCombined=new n.FourKeyMap,this._pages=[],this._activePages=[],this._workBoundingBox={top:0,left:0,bottom:0,right:0},this._workAttributeData=new h.AttributeData,this._textureSize=512,this._onAddTextureAtlasCanvas=new l.Emitter,this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=new l.Emitter,this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._requestClearModel=!1,this._clearModelGeneration=0,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT)){const t=this._drawToCache(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,t)}})}beginFrame(){const t=this._requestClearModel;return this._requestClearModel=!1,t}clearTexture(){if(0!==this._pages[0].currentRow.x||0!==this._pages[0].currentRow.y){for(const t of this._pages)t.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1,this._requestClearModel=!0,this._clearModelGeneration++}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t-e),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version=++Q.nextVersion;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=h.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,T,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,T,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=h.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),T&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,h,T){if(1===this._config.minimumContrastRatio||T)return;const l=this._getContrastCache(h),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(h?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const T="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(T.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h+_,s),this._tmpCtx.lineTo(T,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(h+_,s),this._tmpCtx.moveTo(h+_+t,s),this._tmpCtx.lineTo(T,s)),o=(0,a.computeNextVariantOffset)(T-h,t,o);break;case 5:const Q=.6,u=.3,p=T-h,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s);break;default:this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==T){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(T);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(T,S,S+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight),"_"===T&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,D,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,S);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version=++Q.nextVersion,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,h=i.rgba>>>8&255,T=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-h))/12);let l=!0;for(let e=0;ethis._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,d,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(d.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new T.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{this._logService.debug("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e);let i=!1,s=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&s++<32;)i=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);i&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,T,l,d,_,Q,u,p,g,M,f,C,m=this._workCell;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp;const t=i.buffer.lines.get(a);if(!t){for(this._model.lineLengths[r]=0,f=0;f=T,d=f,n.length>0&&f===n[0][0]&&l){_=n.shift();const t=this._model.selection.isCellSelected(this._terminal,_[0],a);for(M=_[0]+1;M<_[1];M++)l&&=t===this._model.selection.isCellSelected(this._terminal,M,a);l&&=!Q||E<_[0]||E>=_[1],l?(L=!0,m=new y(m,o.translateToString(!0,_[0],_[1]),_[1]-_[0]),d=_[1]-1):T=_[1]}if(u=m.getChars(),p=m.getCode(),M=4*(r*i.cols+f),!e&&m.isBlink()&&(e=!0),this._cellColorResolver.resolve(m,f,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(f===E&&(this._model.cursor={x:E,y:b,width:m.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+m.getWidth()-1),f>=E&&f<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&m.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),p!==h.NULL_CELL_CODE&&(this._model.lineLengths[r]=f+1),(this._model.cells[M]!==p||this._model.cells[M+1]!==this._cellColorResolver.result.bg||this._model.cells[M+2]!==this._cellColorResolver.result.fg||this._model.cells[M+3]!==this._cellColorResolver.result.ext)&&(A=!0,u.length>1&&(p|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[M]=p,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext,g=m.getWidth(),this._glyphRenderer.value.updateCell(f,r,p,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,u,g,s),L)){for(m=this._workCell,f++;f<=d;f++)C=4*(r*i.cols+f),this._nullModelCell(f,r,C,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);f--}}this._setRowBlinkState(r,e)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,e,i,s,r,a){this._glyphRenderer.value.updateCell(t,e,h.NULL_CELL_CODE,s,r,a,h.NULL_CELL_CHAR,0,0),this._model.cells[i]=h.NULL_CELL_CODE,this._model.cells[i+1]=s,this._model.cells[i+2]=r,this._model.cells[i+3]=a}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{constructor(t,e,i){super(),this.content=0,this.combinedData="",this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i,a){const o=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e,a))),t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i,a))),t.linkProgram(o),t.getProgramParameter(o,t.LINK_STATUS))return o;a.error(t.getProgramInfoLog(o)),t.deleteProgram(o)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function r(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(i||(i={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":i.LINE_H,"":i.LINE_V,"":i.FADE_RIGHT,"":i.FADE_LEFT,"":i.FADE_DOWN,"":i.FADE_UP,"":i.CURVE_DOWN_RIGHT,"":i.CURVE_DOWN_LEFT,"":i.CURVE_UP_RIGHT,"":i.CURVE_UP_LEFT,"":[i.LINE_V,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT],"":[i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_UP_RIGHT],"":[i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE],"":i.NODE_STROKE,"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"🬀":s(1),"🬁":s(2),"🬂":s(3),"🬃":s(4),"🬄":s(5),"🬅":s(6),"🬆":s(7),"🬇":s(8),"🬈":s(9),"🬉":s(10),"🬊":s(11),"🬋":s(12),"🬌":s(13),"🬍":s(14),"🬎":s(15),"🬏":s(16),"🬐":s(17),"🬑":s(18),"🬒":s(19),"🬓":s(20),"🬔":s(22),"🬕":s(23),"🬖":s(24),"🬗":s(25),"🬘":s(26),"🬙":s(27),"🬚":s(28),"🬛":s(29),"🬜":s(30),"🬝":s(31),"🬞":s(32),"🬟":s(33),"🬠":s(34),"🬡":s(35),"🬢":s(36),"🬣":s(37),"🬤":s(38),"🬥":s(39),"🬦":s(40),"🬧":s(41),"🬨":s(43),"🬩":s(44),"🬪":s(45),"🬫":s(46),"🬬":s(47),"🬭":s(48),"🬮":s(49),"🬯":s(50),"🬰":s(51),"🬱":s(52),"🬲":s(53),"🬳":s(54),"🬴":s(55),"🬵":s(56),"🬶":s(57),"🬷":s(58),"🬸":s(59),"🬹":s(60),"🬺":s(61),"🬻":s(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:r(126),type:0},scaleType:1},"🯱":{type:5,data:{d:r(48),type:0},scaleType:1},"🯲":{type:5,data:{d:r(109),type:0},scaleType:1},"🯳":{type:5,data:{d:r(121),type:0},scaleType:1},"🯴":{type:5,data:{d:r(51),type:0},scaleType:1},"🯵":{type:5,data:{d:r(91),type:0},scaleType:1},"🯶":{type:5,data:{d:r(95),type:0},scaleType:1},"🯷":{type:5,data:{d:r(114),type:0},scaleType:1},"🯸":{type:5,data:{d:r(127),type:0},scaleType:1},"🯹":{type:5,data:{d:r(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,h,T,l,d,c,_=0){const Q=r.customGlyphDefinitions[e];if(Q){const e=Array.isArray(Q)?Q:[Q];for(const r of e)a(t,r,i,s,o,n,L,h,T,l,d,c,_);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,h,d,c,_,Q,u,p,g=0){let M=a,y=h,v=i,f=r;switch(1===e.scaleType&&(M=d,y=c,v=i+(a-d)/2,f=r+(h-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,h=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,h):"L"===e&&t.lineTo(L,h)}t.clip()}(t,e.clipPath,v,f,M,y)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(T.slice(7,9),16)||1;else{if(!T.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${T}" when drawing pattern glyph`);[L,d,c,_]=T.substring(5,T.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,v,f,M,y,g);break;case 2:!function(t,e,i,s,r,a,o,n,L){let h;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),h="function"==typeof e?e(.15,.15/a*r):e;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of h.split(" ")){const L=e[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const h=T[L];if(!h){n.error(`Could not find drawing instructions for "${L}"`);continue}const c=e.substring(1).split(",");c[0]&&c[1]&&(h(t,l(c,r,a,i,s,!0,o,0,0,!1),d),d.lastCommand=L)}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,v,f,M,y,Q,u,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const h="string"==typeof e?e:e(0,0);t.beginPath();let T=0,l=0,d=0,c=0,_="";for(const e of h.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(T,e),l=e,d=T,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),T=e,d=T,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,h=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3],10),c=parseInt(L[4],10),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,T,l,e,o,h,d,c,_,Q),T=_,l=Q;continue}const h=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("L"===o)t.lineTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("Q"===o)t.quadraticCurveTo(h[0],h[1],h[2],h[3]),d=h[0],c=h[1],T=h[2],l=h[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*T-d,i=2*l-c):(e=T,i=l),t.quadraticCurveTo(e,i,h[0],h[1]),d=e,c=i,T=h[0],l=h[1]}else"C"===o&&(t.bezierCurveTo(h[0],h[1],h[2],h[3],h[4],h[5]),d=h[2],c=h[3],T=h[4],l=h[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,v,f,M,y,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,v,f,M,y,Q,p);break;case 5:!function(t,e,i,s,r,a,o,n,L){const h=new Path2D;h.rect(i,s,r,a),t.clip(h),t.beginPath();const d=o/12;t.lineWidth=n*d;const c={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const h=o[0];if("Z"===h){t.closePath(),c.lastCommand=h;continue}const _=T[h];if(!_){L.error(`Could not find drawing instructions for "${h}"`);continue}const Q=o.substring(1).split(",");Q[0]&&Q[1]&&(_(t,l(Q,r,a,i,s,!1,n,(e.leftPadding??0)*(d/2),(e.rightPadding??0)*(d/2)),c),c.lastCommand=h)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,v,f,M,y,_,Q,u);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,h=.8*a/8,T=Math.min(n,h);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=T*C-l*m+(e+L)/2,x=l*C+T*m+(i+h)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const D=A+Z;t.ellipse(w,x,s,r,a,A,D,0===n)}const L=new Map;function h(t,e,i=0){return Math.max(Math.min(t,e),i)}const T={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,T=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;t{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("WebGL2 is only supported on Safari 16 and above")}super(),this._onChangeTextureAtlas=this._register(new o.Emitter),this.onChangeTextureAtlas=this._onChangeTextureAtlas.event,this._onAddTextureAtlasCanvas=this._register(new o.Emitter),this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=this._register(new o.Emitter),this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._onContextLoss=this._register(new o.Emitter),this.onContextLoss=this._onContextLoss.event,this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,h=n._characterJoinerService,T=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._logService,_=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,h,T,l,s,d,c,r,_,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.WebglAddon=e():t.WebglAddon=e()}(globalThis,()=>(()=>{"use strict";var t={105(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.WindowIntervalTimer=e.eventType=void 0,e.getWindow=r,e.addDisposableListener=o,e.addStandardDisposableListener=function(t,e,i,s){return o(t,e,i,s)},e.getDomNodePagePosition=function(t){const e=t.getBoundingClientRect(),i=r(t);return{left:e.left+i.scrollX,top:e.top+i.scrollY,width:e.width,height:e.height}},e.scheduleAtNextAnimationFrame=function(t,e,i=0){const s=h(t),r=new n(e,i);return s.next.push(r),s.animFrameRequested||(s.animFrameRequested=!0,t.requestAnimationFrame(()=>function(t){const e=h(t);for(e.animFrameRequested=!1,e.current=e.next,e.next=[],e.inAnimationFrameRunner=!0;e.current.length>0;)e.current.sort(n.sort),e.current.shift().execute();e.inAnimationFrameRunner=!1}(t))),r};const s=i(578);function r(t){const e=t;if(e?.ownerDocument?.defaultView)return e.ownerDocument.defaultView;const i=t;return i?.view?i.view:window}class a{constructor(t,e,i,s){this._node=t,this._type=e,this._handler=i,this._options=s,t.addEventListener(e,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function o(t,e,i,s){return new a(t,e,i,s)}e.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class n{constructor(t,e){this._runner=t,this.priority=e,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(t){console.error(t)}}static sort(t,e){return e.priority-t.priority}}const L=new Map;function h(t){let e=L.get(t);return e||(e={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},L.set(t,e)),e}class T extends s.IntervalTimer{constructor(t){super(),this._defaultTarget=t?r(t):void 0}cancelAndSet(t,e,i){super.cancelAndSet(t,e,i??this._defaultTarget??window)}}e.WindowIntervalTimer=T},814(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.INVERTED_DEFAULT_COLOR=void 0,e.INVERTED_DEFAULT_COLOR=257},959(t,e){function i(t){return 57508<=t&&t<=57558}function s(t){return t>=128512&&t<=128591||t>=127744&&t<=128511||t>=128640&&t<=128767||t>=9728&&t<=9983||t>=9984&&t<=10175||t>=65024&&t<=65039||t>=129280&&t<=129535||t>=127462&&t<=127487}Object.defineProperty(e,"__esModule",{value:!0}),e.throwIfFalsy=function(t){if(!t)throw new Error("value must not be falsy");return t},e.isPowerlineGlyph=i,e.isRestrictedPowerlineGlyph=function(t){return 57520<=t&&t<=57527},e.isEmoji=s,e.allowRescaling=function(t,e,r,a){return 1===e&&r>Math.ceil(1.5*a)&&void 0!==t&&t>255&&!s(t)&&!i(t)&&!function(t){return 57344<=t&&t<=63743}(t)},e.treatGlyphAsBackgroundColor=function(t){return i(t)||function(t){return 9472<=t&&t<=9631}(t)},e.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},e.computeNextVariantOffset=function(t,e,i=0){return(t-(2*Math.round(e)-i))%(2*Math.round(e))}},948(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(t,e,i,s=!1){if(this.selectionStart=e,this.selectionEnd=i,!e||!i||e[0]===i[0]&&e[1]===i[1])return void this.clear();const r=t.buffers.active.ydisp,a=e[1]-r,o=i[1]-r,n=Math.max(a,0),L=Math.min(o,t.rows-1);n>=t.rows||L<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=n,this.viewportCappedEndRow=L,this.startCol=e[0],this.endCol=i[0])}isCellSelected(t,e,i){return!!this.hasSelection&&(i-=t.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&i>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&e=this.startCol)}}},536(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextBlinkStateManager=void 0;const s=i(426);class r extends s.Disposable{constructor(t,e,i){super(),this._renderCallback=t,this._coreBrowserService=e,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",t=>{this.setIntervalDuration(t)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const t=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(t||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}e.TextBlinkStateManager=r},578(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.IntervalTimer=e.MicrotaskTimer=e.TimeoutTimer=void 0,e.timeout=function(t){return new Promise(e=>setTimeout(e,t))},e.disposableTimeout=function(t,e=0,i){const r=setTimeout(()=>{t(),i&&a.dispose()},e),a=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(a),a};const s=i(426);e.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(t,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,t()},e)}setIfNotSet(t,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,t()},e))}},e.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(t){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,t())}))}},e.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(t,e,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{t()},e);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=l,e.contrastRatio=d;let i=0,s=0,r=0,a=0;var o,n,L,h,T;function l(t){const e=t.toString(16);return e.length<2?"0"+e:e}function d(t,e){return t>>0},t.toColor=function(e,i,s,r){return{css:t.toCss(e,i,s,r),rgba:t.toRgba(e,i,s,r)}}}(o||(e.channels=o={})),function(t){function e(t,e){return a=Math.round(255*e),[i,s,r]=T.toChannels(t.rgba),{css:o.toCss(i,s,r,a),rgba:o.toRgba(i,s,r,a)}}t.blend=function(t,e){if(a=(255&e.rgba)/255,1===a)return{css:e.css,rgba:e.rgba};const n=e.rgba>>24&255,L=e.rgba>>16&255,h=e.rgba>>8&255,T=t.rgba>>24&255,l=t.rgba>>16&255,d=t.rgba>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,i){const s=T.ensureContrastRatio(t.rgba,e.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[i,s,r]=T.toChannels(e),{css:o.toCss(i,s,r),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,i){return a=255&t.rgba,e(t,a*i/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(n||(e.color=n={})),function(t){let e,n;try{const t=document.createElement("canvas");t.width=1,t.height=1;const i=t.getContext("2d",{willReadFrequently:!0});i&&(e=i,e.globalCompositeOperation="copy",n=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),r=parseInt(t.slice(3,4).repeat(2),16),a=parseInt(t.slice(4,5).repeat(2),16),o.toColor(i,s,r,a);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const L=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(L)return i=parseInt(L[1],10),s=parseInt(L[2],10),r=parseInt(L[3],10),a=Math.round(255*(void 0===L[5]?1:parseFloat(L[5]))),o.toColor(i,s,r,a);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!n)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=n,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[i,s,r,a]=e.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,a),css:t}}}(L||(e.css=L={})),function(t){function e(t,e,i){const s=t/255,r=e/255,a=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(h||(e.rgb=h={})),function(t){function e(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T0||n>0||L>0);)o-=Math.max(0,Math.ceil(.1*o)),n-=Math.max(0,Math.ceil(.1*n)),L-=Math.max(0,Math.ceil(.1*L)),T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));return(o<<24|n<<16|L<<8|255)>>>0}function n(t,e,i){const s=t>>24&255,r=t>>16&255,a=t>>8&255;let o=e>>24&255,n=e>>16&255,L=e>>8&255,T=d(h.relativeLuminance2(o,n,L),h.relativeLuminance2(s,r,a));for(;T>>0}t.blend=function(t,e){if(a=(255&e)/255,1===a)return e;const n=e>>24&255,L=e>>16&255,h=e>>8&255,T=t>>24&255,l=t>>16&255,d=t>>8&255;return i=T+Math.round((n-T)*a),s=l+Math.round((L-l)*a),r=d+Math.round((h-d)*a),o.toRgba(i,s,r)},t.ensureContrastRatio=function(t,i,s){const r=h.relativeLuminance(t>>8),a=h.relativeLuminance(i>>8);if(d(r,a)>8));if(od(r,h.relativeLuminance(e>>8))?a:e}return a}const o=n(t,i,s),L=d(r,h.relativeLuminance(o>>8));if(Ld(r,h.relativeLuminance(a>>8))?o:a}return o}},t.reduceLuminance=e,t.increaseLuminance=n,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(T||(e.rgba=T={}))},414(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.EventUtils=e.Emitter=void 0;const s=i(426);var r;e.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(t,e,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:t,thisArgs:e};this._listeners.push(r);const a=(0,s.toDisposable)(()=>{const t=this._listeners.indexOf(r);-1!==t&&this._listeners.splice(t,1)});return i&&(Array.isArray(i)?i.push(a):i.add(a)),a}),this._event}fire(t){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:e,thisArgs:i}=this._listeners[0];return void e.call(i,t)}default:{const e=this._listeners.slice();for(const{fn:i,thisArgs:s}of e)i.call(s,t)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(t){t.forward=function(t,e){return t(t=>e.fire(t))},t.map=function(t,e){return(i,s,r)=>t(t=>i.call(s,e(t)),void 0,r)},t.any=function(...t){return(e,i,r)=>{const a=new s.DisposableStore;for(const s of t)a.add(s(t=>e.call(i,t)));return r&&(Array.isArray(r)?r.push(a):r.add(a)),a}},t.runAndSubscribe=function(t,e,i){return e(i),t(t=>e(t))}}(r||(e.EventUtils=r={}))},426(t,e){function i(t){return{dispose:t}}function s(t){if(!t)return t;if(Array.isArray(t)){for(const e of t)e.dispose();return[]}return t.dispose(),t}Object.defineProperty(e,"__esModule",{value:!0}),e.MutableDisposable=e.Disposable=e.DisposableStore=void 0,e.toDisposable=i,e.dispose=s,e.combinedDisposable=function(...t){return i(()=>s(t))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(t){return this._isDisposed?t.dispose():this._disposables.add(t),t}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const t of this._disposables)t.dispose();this._disposables.clear()}}clear(){for(const t of this._disposables)t.dispose();this._disposables.clear()}}e.DisposableStore=r;class a{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(t){return this._store.add(t)}}e.Disposable=a,a.None=Object.freeze({dispose(){}}),e.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(t){this._isDisposed||t===this._value||(this._value?.dispose(),this._value=t)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},836(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.FourKeyMap=e.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(t,e,i){this._data[t]||(this._data[t]={}),this._data[t][e]=i}get(t,e){return this._data[t]?this._data[t][e]:void 0}clear(){this._data={}}}e.TwoKeyMap=i,e.FourKeyMap=class{constructor(){this._data=new i}set(t,e,s,r,a){this._data.get(t,e)||this._data.set(t,e,new i),this._data.get(t,e).set(s,r,a)}get(t,e,i,s){return this._data.get(t,e)?.get(i,s)}clear(){this._data.clear()}}},95(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.isChromeOS=e.isLinux=e.isWindows=e.isMac=e.isSafari=e.isLegacyEdge=e.isChrome=e.isFirefox=e.isNode=void 0,e.getZoomFactor=function(t){return 1},e.getSafariVersion=function(){if(!e.isSafari)return 0;const t=i.match(/Version\/(\d+)/);return null===t||t.length<2?0:parseInt(t[1],10)},e.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=e.isNode?"node":navigator.userAgent,s=e.isNode?"node":navigator.platform;e.isFirefox=i.includes("Firefox"),e.isChrome=i.includes("Chrome"),e.isLegacyEdge=i.includes("Edge"),e.isSafari=/^((?!chrome|android).)*safari/i.test(i),e.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),e.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),e.isLinux=s.indexOf("Linux")>=0,e.isChromeOS=/\bCrOS\b/.test(i)},930(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.DebouncedIdleTask=e.IdleTaskQueue=e.PriorityTaskQueue=void 0;class i{constructor(t){this._tasks=[],this._i=0,this._logService=t}enqueue(t){this._tasks.push(t),this._start()}flush(){for(;this._ii)return r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(t){return setTimeout(()=>t(this._createDeadline(16)))}_cancelCallback(t){clearTimeout(t)}_createDeadline(t){const e=performance.now()+t;return{timeRemaining:()=>Math.max(0,e-performance.now())}}}e.PriorityTaskQueue=s,e.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(t){return requestIdleCallback(t)}_cancelCallback(t){cancelIdleCallback(t)}}:s,e.DebouncedIdleTask=class{constructor(t){this._queue=new e.IdleTaskQueue(t)}set(t){this._queue.clear(),this._queue.enqueue(t)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},917(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.ExtendedAttrs=e.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(t){return[t>>>16&255,t>>>8&255,255&t]}static fromColorRGB(t){return(255&t[0])<<16|(255&t[1])<<8|255&t[2]}clone(){const t=new i;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}e.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(t){this._ext&=-67108864,this._ext|=67108863&t}get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){const t=(3758096384&this._ext)>>29;return t<0?4294967288^t:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}constructor(t=0,e=0){this._ext=0,this._urlId=0,this._ext=t,this._urlId=e}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}e.ExtendedAttrs=s},721(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellData=void 0;const s=i(348),r=i(564),a=i(917);class o extends a.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new a.ExtendedAttrs,this.combinedData=""}static fromCharData(t){const e=new o;return e.setFromCharData(t),e}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(t){this.fg=t[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let e=!1;if(t[r.CHAR_DATA_CHAR_INDEX].length>2)e=!0;else if(2===t[r.CHAR_DATA_CHAR_INDEX].length){const i=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|t[r.CHAR_DATA_WIDTH_INDEX]<<22:e=!0}else e=!0}else this.content=t[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[r.CHAR_DATA_WIDTH_INDEX]<<22;e&&(this.combinedData=t[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|t[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor())return!1;if(this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor())return!1;if(this.isInverse()!==t.isInverse())return!1;if(this.isBold()!==t.isBold())return!1;if(this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;const e=this.isUnderlineColorDefault(),i=t.isUnderlineColorDefault();if(!e||!i){if(e!==i)return!1;if(this.getUnderlineColor()!==t.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==t.getUnderlineColorMode())return!1}}return this.isOverline()===t.isOverline()&&this.isBlink()===t.isBlink()&&this.isInvisible()===t.isInvisible()&&this.isItalic()===t.isItalic()&&this.isDim()===t.isDim()&&this.isStrikethrough()===t.isStrikethrough()}}e.CellData=o},564(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.WHITESPACE_CELL_CODE=e.WHITESPACE_CELL_WIDTH=e.WHITESPACE_CELL_CHAR=e.NULL_CELL_CODE=e.NULL_CELL_WIDTH=e.NULL_CELL_CHAR=e.CHAR_DATA_CODE_INDEX=e.CHAR_DATA_WIDTH_INDEX=e.CHAR_DATA_CHAR_INDEX=e.CHAR_DATA_ATTR_INDEX=e.DEFAULT_EXT=e.DEFAULT_ATTR=e.DEFAULT_COLOR=void 0,e.DEFAULT_COLOR=0,e.DEFAULT_ATTR=e.DEFAULT_COLOR<<9|256,e.DEFAULT_EXT=0,e.CHAR_DATA_ATTR_INDEX=0,e.CHAR_DATA_CHAR_INDEX=1,e.CHAR_DATA_WIDTH_INDEX=2,e.CHAR_DATA_CODE_INDEX=3,e.NULL_CELL_CHAR="",e.NULL_CELL_WIDTH=1,e.NULL_CELL_CODE=0,e.WHITESPACE_CELL_CHAR=" ",e.WHITESPACE_CELL_WIDTH=1,e.WHITESPACE_CELL_CODE=32},348(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.Utf8ToUtf32=e.StringToUtf32=void 0,e.stringFromCodePoint=function(t){return t>65535?(t-=65536,String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):String.fromCharCode(t)},e.utf32ToString=function(t,e=0,i=t.length){let s="";for(let r=e;r65535?(e-=65536,s+=String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):s+=String.fromCharCode(e)}return s},e.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(t,e){const i=t.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=t.charCodeAt(r++);56320<=i&&i<=57343?e[s++]=1024*(this._interim-55296)+i-56320+65536:(e[s++]=this._interim,e[s++]=i),this._interim=0}for(let a=r;a=i)return this._interim=r,s;const o=t.charCodeAt(a);56320<=o&&o<=57343?e[s++]=1024*(r-55296)+o-56320+65536:(e[s++]=r,e[s++]=o);continue}65279!==r&&(e[s++]=r)}return s}},e.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(t,e){const i=t.length;if(!i)return 0;let s,r,a,o,n,L=0,h=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let a,o=0;for(;(a=this.interim[++o])&&o<4;)r<<=6,r|=63&a;const n=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,T=n-o;for(;h=i)return 0;if(a=t[h++],128!=(192&a)){h--,s=!0;break}this.interim[o++]=a,r<<=6,r|=63&a}s||(2===n?r<128?h--:e[L++]=r:3===n?r<2048||r>=55296&&r<=57343||65279===r||(e[L++]=r):r<65536||r>1114111||(e[L++]=r)),this.interim.fill(0)}const T=i-4;let l=h;for(;l=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(n=(31&s)<<6|63&r,n<128){l--;continue}e[L++]=n}else if(224==(240&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(n=(15&s)<<12|(63&r)<<6|63&a,n<2048||n>=55296&&n<=57343||65279===n)continue;e[L++]=n}else if(240==(248&s)){if(l>=i)return this.interim[0]=s,L;if(r=t[l++],128!=(192&r)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,L;if(a=t[l++],128!=(192&a)){l--;continue}if(l>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=a,L;if(o=t[l++],128!=(192&o)){l--;continue}if(n=(7&s)<<18|(63&r)<<12|(63&a)<<6|63&o,n<65536||n>1114111)continue;e[L++]=n}}return L}}},864(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.CellColorResolver=void 0;const s=i(564),r=i(993),a=i(959),o=i(915);let n,L=0,h=0,T=!1,l=!1,d=!1,c=0;e.CellColorResolver=class{constructor(t,e,i,s,r,a){this._terminal=t,this._optionService=e,this._selectionRenderModel=i,this._decorationService=s,this._coreBrowserService=r,this._themeService=a,this.result={fg:0,bg:0,ext:0}}resolve(t,e,i,_,Q){this.result.bg=t.bg,this.result.fg=t.fg,this.result.ext=268435456&t.bg?t.extended.ext:0,h=0,L=0,l=!1,T=!1,d=!1,n=this._themeService.colors,c=0;const u=t.getCode();if(u!==s.NULL_CELL_CODE&&4===t.extended.underlineStyle){const t=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));c=e*_%(2*Math.round(t))}if(0===c&&o.blockPatternCodepoints.has(u)&&(c=e*_%2*2+i*Q%2),this._decorationService.forEachDecorationAtCell(e,i,"bottom",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),d=this._selectionRenderModel.isCellSelected(this._terminal,e,i),d){if(67108864&this.result.fg||50331648&this.result.bg){if(67108864&this.result.fg)switch(50331648&this.result.fg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:h=(16777215&this.result.fg)<<8|255;break;default:h=this._themeService.colors.foreground.rgba}else switch(50331648&this.result.bg){case 16777216:case 33554432:h=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:h=(16777215&this.result.bg)<<8|255}h=r.rgba.blend(h,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}else h=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(l=!0,n.selectionForeground&&(L=n.selectionForeground.rgba>>8&16777215,T=!0),(0,a.treatGlyphAsBackgroundColor)(t.getCode())){if(67108864&this.result.fg&&!(50331648&this.result.bg))L=(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(67108864&this.result.fg)switch(50331648&this.result.bg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.bg].rgba;break;case 50331648:L=(16777215&this.result.bg)<<8|255}else switch(50331648&this.result.fg){case 16777216:case 33554432:L=this._themeService.colors.ansi[255&this.result.fg].rgba;break;case 50331648:L=(16777215&this.result.fg)<<8|255;break;default:L=this._themeService.colors.foreground.rgba}L=r.rgba.blend(L,4294967040&(this._coreBrowserService.isFocused?n.selectionBackgroundOpaque:n.selectionInactiveBackgroundOpaque).rgba|128)>>8&16777215}T=!0}}this._decorationService.forEachDecorationAtCell(e,i,"top",t=>{t.backgroundColorRGB&&(h=t.backgroundColorRGB.rgba>>8&16777215,l=!0),t.foregroundColorRGB&&(L=t.foregroundColorRGB.rgba>>8&16777215,T=!0)}),l&&(h=d?-16777216&t.bg&-134217729|h|50331648:-16777216&t.bg|h|50331648),T&&(L=-16777216&t.fg&-67108865|L|50331648),67108864&this.result.fg&&(l&&!T&&(L=50331648&this.result.bg?-134217728&this.result.fg|67108863&this.result.bg:-134217728&this.result.fg|16777215&n.background.rgba>>8|50331648,T=!0),!l&&T&&(h=50331648&this.result.fg?-67108864&this.result.bg|67108863&this.result.fg:-67108864&this.result.bg|16777215&n.foreground.rgba>>8|50331648,l=!0)),n=void 0,this.result.bg=l?h:this.result.bg,this.result.fg=T?L:this.result.fg,this.result.ext&=536870911,this.result.ext|=c<<29&3758096384}}},670(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.acquireTextureAtlas=function(t,e,i,o,n,L,h,T,l,d=!0){const c=(0,r.generateConfig)(o,n,L,h,e,i,T,l,d);for(let e=0;e=0){if((0,r.configEquals)(i.config,c))return i.atlas;1===i.ownedBy.length?(i.atlas.dispose(),a.splice(e,1)):i.ownedBy.splice(s,1);break}}for(let e=0;e{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(t=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0)return void this._restartInterval(t)}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){const t=600-(Date.now()-this._animationTimeRestarted);return this._animationTimeRestarted=void 0,void this._restartInterval(t)}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},t)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}}},697(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.observeDevicePixelDimensions=function(t,e,i){let r=new e.ResizeObserver(e=>{const s=e.find(e=>e.target===t);if(!s)return;if(!("devicePixelContentBoxSize"in s))return r?.disconnect(),void(r=void 0);const a=s.devicePixelContentBoxSize[0].inlineSize,o=s.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&i(a,o)});try{r.observe(t,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return(0,s.toDisposable)(()=>r?.disconnect())};const s=i(426)},28(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GlyphRenderer=void 0;const s=i(115),r=i(564),a=i(426),o=i(719),n=i(959);let L,h=0,T=0,l=0;class d extends a.Disposable{constructor(t,e,i,r,L){super(),this._terminal=t,this._gl=e,this._dimensions=i,this._optionsService=r,this._logService=L,this._activeBuffer=0,this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};const h=this._gl;void 0===s.TextureAtlas.maxAtlasPages&&(s.TextureAtlas.maxAtlasPages=Math.min(32,(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_IMAGE_UNITS))),s.TextureAtlas.maxTextureSize=(0,n.throwIfFalsy)(h.getParameter(h.MAX_TEXTURE_SIZE))),this._program=(0,n.throwIfFalsy)((0,o.createProgram)(h,"#version 300 es\nlayout (location = 0) in vec2 a_unitquad;\nlayout (location = 1) in vec2 a_cellpos;\nlayout (location = 2) in vec2 a_offset;\nlayout (location = 3) in vec2 a_size;\nlayout (location = 4) in float a_texpage;\nlayout (location = 5) in vec2 a_texcoord;\nlayout (location = 6) in vec2 a_texsize;\n\nuniform mat4 u_projection;\nuniform vec2 u_resolution;\n\nout vec2 v_texcoord;\nflat out int v_texpage;\n\nvoid main() {\n vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size);\n gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0);\n v_texpage = int(a_texpage);\n v_texcoord = a_texcoord + a_unitquad * a_texsize;\n}",function(t){let e="";for(let i=1;ih.deleteProgram(this._program))),this._projectionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_resolution")),this._textureLocation=(0,n.throwIfFalsy)(h.getUniformLocation(this._program,"u_texture"));const T=this._vertexArrayObject=h.createVertexArray();this._register((0,a.toDisposable)(()=>h.deleteVertexArray(T))),h.bindVertexArray(T);const l=new Float32Array([0,0,1,0,0,1,1,1]),d=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(d))),h.bindBuffer(h.ARRAY_BUFFER,d),h.bufferData(h.ARRAY_BUFFER,l,h.STATIC_DRAW),h.enableVertexAttribArray(0),h.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);const c=new Uint8Array([0,1,2,3]),_=h.createBuffer();this._register((0,a.toDisposable)(()=>h.deleteBuffer(_))),h.bindBuffer(h.ELEMENT_ARRAY_BUFFER,_),h.bufferData(h.ELEMENT_ARRAY_BUFFER,c,h.STATIC_DRAW),this._attributesBuffer=(0,n.throwIfFalsy)(h.createBuffer()),this._register((0,a.toDisposable)(()=>h.deleteBuffer(this._attributesBuffer))),h.bindBuffer(h.ARRAY_BUFFER,this._attributesBuffer),h.enableVertexAttribArray(2),h.vertexAttribPointer(2,2,h.FLOAT,!1,44,0),h.vertexAttribDivisor(2,1),h.enableVertexAttribArray(3),h.vertexAttribPointer(3,2,h.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(3,1),h.enableVertexAttribArray(4),h.vertexAttribPointer(4,1,h.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(4,1),h.enableVertexAttribArray(5),h.vertexAttribPointer(5,2,h.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(5,1),h.enableVertexAttribArray(6),h.vertexAttribPointer(6,2,h.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(6,1),h.enableVertexAttribArray(1),h.vertexAttribPointer(1,2,h.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),h.vertexAttribDivisor(1,1),h.useProgram(this._program);const Q=new Int32Array(s.TextureAtlas.maxAtlasPages);for(let t=0;th.deleteTexture(e.texture))),h.activeTexture(h.TEXTURE0+t),h.bindTexture(h.TEXTURE_2D,e.texture),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_S,h.CLAMP_TO_EDGE),h.texParameteri(h.TEXTURE_2D,h.TEXTURE_WRAP_T,h.CLAMP_TO_EDGE),h.texImage2D(h.TEXTURE_2D,0,h.RGBA,1,1,0,h.RGBA,h.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[t]=e}h.enable(h.BLEND),h.blendFunc(h.SRC_ALPHA,h.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){if(!this._atlas)return!0;const _g=this._atlas._clearModelGeneration||0;return this._lastSeenClearModelGeneration!==_g&&(this._lastSeenClearModelGeneration=_g,!0)}updateCell(t,e,i,s,r,a,o,n,L){this._updateCell(this._vertices.attributes,t,e,i,s,r,a,o,n,L)}_updateCell(t,e,i,s,a,o,d,c,_,Q){h=11*(i*this._terminal.cols+e),s!==r.NULL_CELL_CODE&&void 0!==s?this._atlas&&(L=c&&c.length>1?this._atlas.getRasterizedGlyphCombinedChar(c,a,o,d,!1,this._terminal.element):this._atlas.getRasterizedGlyph(s,a,o,d,!1,this._terminal.element),T=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),a!==Q&&L.offset.x>T?(l=L.offset.x-T,t[h]=-(L.offset.x-l)+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=(L.size.x-l)/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x+l/this._atlas.pages[L.texturePage].canvas.width,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x-l/this._atlas.pages[L.texturePage].canvas.width,t[h+8]=L.sizeClipSpace.y):(t[h]=-L.offset.x+this._dimensions.device.char.left,t[h+1]=-L.offset.y+this._dimensions.device.char.top,t[h+2]=L.size.x/this._dimensions.device.canvas.width,t[h+3]=L.size.y/this._dimensions.device.canvas.height,t[h+4]=L.texturePage,t[h+5]=L.texturePositionClipSpace.x,t[h+6]=L.texturePositionClipSpace.y,t[h+7]=L.sizeClipSpace.x,t[h+8]=L.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&(0,n.allowRescaling)(s,_,L.size.x,this._dimensions.device.cell.width)&&(t[h+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width)):t.fill(0,h,h+11-1-2)}clear(){const t=this._terminal,e=t.cols*t.rows*11;this._vertices.count!==e?this._vertices.attributes=new Float32Array(e):this._vertices.attributes.fill(0);let i=0;for(;iT.deleteProgram(this._program))),this._projectionLocation=(0,a.throwIfFalsy)(T.getUniformLocation(this._program,"u_projection"));const l=this._vertexArrayObject=T.createVertexArray();this._register((0,s.toDisposable)(()=>T.deleteVertexArray(l))),T.bindVertexArray(l);const d=new Float32Array([0,0,1,0,0,1,1,1]),c=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(c))),T.bindBuffer(T.ARRAY_BUFFER,c),T.bufferData(T.ARRAY_BUFFER,d,T.STATIC_DRAW),T.enableVertexAttribArray(3),T.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);const _=new Uint8Array([0,1,2,3]),Q=T.createBuffer();this._register((0,s.toDisposable)(()=>T.deleteBuffer(Q))),T.bindBuffer(T.ELEMENT_ARRAY_BUFFER,Q),T.bufferData(T.ELEMENT_ARRAY_BUFFER,_,T.STATIC_DRAW),this._attributesBuffer=(0,a.throwIfFalsy)(T.createBuffer()),this._register((0,s.toDisposable)(()=>T.deleteBuffer(this._attributesBuffer))),T.bindBuffer(T.ARRAY_BUFFER,this._attributesBuffer),T.enableVertexAttribArray(0),T.vertexAttribPointer(0,2,T.FLOAT,!1,o,0),T.vertexAttribDivisor(0,1),T.enableVertexAttribArray(1),T.vertexAttribPointer(1,2,T.FLOAT,!1,o,2*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(1,1),T.enableVertexAttribArray(2),T.vertexAttribPointer(2,4,T.FLOAT,!1,o,4*Float32Array.BYTES_PER_ELEMENT),T.vertexAttribDivisor(2,1),this._updateCachedColors(L.colors),this._register(this._themeService.onChangeColors(t=>{this._updateCachedColors(t),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){const e=this._gl;e.useProgram(this._program),e.bindVertexArray(this._vertexArrayObject),e.uniformMatrix4fv(this._projectionLocation,!1,r.PROJECTION_MATRIX),e.bindBuffer(e.ARRAY_BUFFER,this._attributesBuffer),e.bufferData(e.ARRAY_BUFFER,t.attributes,e.DYNAMIC_DRAW),e.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,e.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){const e=this._terminal,i=this._vertices;let s,r,a,o,n,L,h,T,l,d,c,_=1;for(s=0;s>24&255)/255,d=(L>>16&255)/255,c=(L>>8&255)/255,_=1,this._addRectangle(t.attributes,e,h,T,(o-a)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,l,d,c,_)}_addRectangle(t,e,i,s,r,a,o,n,L,h){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o,t[e+5]=n,t[e+6]=L,t[e+7]=h}_addRectangleFloat(t,e,i,s,r,a,o){t[e]=i/this._dimensions.device.canvas.width,t[e+1]=s/this._dimensions.device.canvas.height,t[e+2]=r/this._dimensions.device.canvas.width,t[e+3]=a/this._dimensions.device.canvas.height,t[e+4]=o[0],t[e+5]=o[1],t[e+6]=o[2],t[e+7]=o[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(255&t.rgba)/255])}}e.RectangleRenderer=Q},208(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.RenderModel=e.COMBINED_CHAR_BIT_MASK=void 0;const s=i(948);e.COMBINED_CHAR_BIT_MASK=2147483648,e.RenderModel=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=(0,s.createSelectionRenderModel)()}resize(t,e){const i=t*e*4;i!==this.cells.length&&(this.cells=new Uint32Array(i),this.lineLengths=new Uint32Array(e))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}}},115(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.TextureAtlas=void 0;const s=i(0),r=i(183),a=i(959),o=i(993),n=i(836),L=i(930),h=i(917),T=i(564),l=i(414),d={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}};let c;class _{get pages(){return this._pages}constructor(t,e,i,s){this._document=t,this._config=e,this._unicodeService=i,this._logService=s,this._didWarmUp=!1,this._cacheMap=new n.FourKeyMap,this._cacheMapCombined=new n.FourKeyMap,this._pages=[],this._activePages=[],this._workBoundingBox={top:0,left:0,bottom:0,right:0},this._workAttributeData=new h.AttributeData,this._textureSize=512,this._onAddTextureAtlasCanvas=new l.Emitter,this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=new l.Emitter,this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._requestClearModel=!1,this._clearModelGeneration=0,this._createNewPage(),this._tmpCanvas=p(t,4*this._config.deviceCellWidth+4,this._config.deviceCellHeight+4),this._tmpCtx=(0,a.throwIfFalsy)(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}dispose(){this._tmpCanvas.remove();for(const t of this.pages)t.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){const t=new L.IdleTaskQueue(this._logService);for(let e=33;e<126;e++)t.enqueue(()=>{if(!this._cacheMap.get(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT)){const t=this._drawToCache(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,!1,void 0);this._cacheMap.set(e,T.DEFAULT_COLOR,T.DEFAULT_COLOR,T.DEFAULT_EXT,t)}})}beginFrame(){const t=this._requestClearModel;return this._requestClearModel=!1,t}clearTexture(){if(this._pages.some(t=>t._glyphs.length>0||0!==t.currentRow.x||0!==t.currentRow.y)){for(const t of this._pages)t.clear();this._activePages.length=0;for(const t of this._pages)t!==this._overflowSizePage&&this._activePages.push(t);this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1,this._requestClearModel=!0,this._clearModelGeneration++}}_createNewPage(){if(_.maxAtlasPages&&this._pages.length>=Math.max(4,_.maxAtlasPages)){const t=this._pages.filter(t=>2*t.canvas.width<=(_.maxTextureSize||4096)).sort((t,e)=>e.canvas.width!==t.canvas.width?e.canvas.width-t.canvas.width:e.percentageUsed-t.percentageUsed);let e=-1,i=0;for(let s=0;st.canvas.width!==s[0].canvas.width)){const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}const r=s.map(t=>t.glyphs[0].texturePage).sort((t,e)=>t-e),a=this.pages.length-s.length,o=this._mergePages(s,a);o.version=++Q.nextVersion;for(let t=r.length-1;t>=0;t--)this._deletePage(r[t]);this.pages.push(o),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(o.canvas)}const t=new Q(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(t,e){const i=2*t[0].canvas.width,s=new Q(this._document,i,t);for(const[r,a]of t.entries()){const t=r*a.canvas.width%i,o=Math.floor(r/2)*a.canvas.height;s.ctx.drawImage(a.canvas,t,o);for(const s of a.glyphs)s.texturePage=e,s.sizeClipSpace.x=s.size.x/i,s.sizeClipSpace.y=s.size.y/i,s.texturePosition.x+=t,s.texturePosition.y+=o,s.texturePositionClipSpace.x=s.texturePosition.x/i,s.texturePositionClipSpace.y=s.texturePosition.y/i;this._onRemoveTextureAtlasCanvas.fire(a.canvas);const n=this._activePages.indexOf(a);-1!==n&&this._activePages.splice(n,1)}return s}_deletePage(t){this._pages.splice(t,1);for(let e=t;e=this._config.colors.ansi.length)throw new Error("No color found for idx "+t);return this._config.colors.ansi[t]}_getBackgroundColor(t,e,i,s){if(this._config.allowTransparency)return o.NULL_COLOR;let r;switch(t){case 16777216:case 33554432:r=this._getColorFromAnsiIndex(e);break;case 50331648:const t=h.AttributeData.toColorRGB(e);r=o.channels.toColor(t[0],t[1],t[2]);break;default:r=i?o.color.opaque(this._config.colors.foreground):this._config.colors.background}return this._config.allowTransparency||(r=o.color.opaque(r)),r}_getForegroundColor(t,e,i,r,a,n,L,T,l,d){const c=this._getMinimumContrastColor(t,e,i,r,a,n,L,l,T,d);if(c)return c;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&l&&n<8&&(n+=8),_=this._getColorFromAnsiIndex(n);break;case 50331648:const t=h.AttributeData.toColorRGB(n);_=o.channels.toColor(t[0],t[1],t[2]);break;default:_=L?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(_=o.color.opaque(_)),T&&(_=o.color.multiplyOpacity(_,s.DIM_OPACITY)),_}_resolveBackgroundRgba(t,e,i){switch(t){case 16777216:case 33554432:return this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(t,e,i,s){switch(t){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&e<8&&(e+=8),this._getColorFromAnsiIndex(e).rgba;case 50331648:return e<<8;default:return i?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(t,e,i,s,r,a,n,L,h,T){if(1===this._config.minimumContrastRatio||T)return;const l=this._getContrastCache(h),d=l.getColor(t,s);if(void 0!==d)return d??void 0;const c=this._resolveBackgroundRgba(e,i,n),_=this._resolveForegroundRgba(r,a,n,L),Q=o.rgba.ensureContrastRatio(c,_,this._config.minimumContrastRatio/(h?2:1));if(!Q)return void l.setColor(t,s,null);const u=o.channels.toColor(Q>>24&255,Q>>16&255,Q>>8&255);return l.setColor(t,s,u),u}_getContrastCache(t){return t?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,e,i,o,n,L){const T="number"==typeof t?String.fromCharCode(t):t;L&&this._tmpCanvas.parentElement!==L&&(this._tmpCanvas.style.display="none",L.append(this._tmpCanvas));const l=Math.min(this._config.deviceCellWidth*Math.max(T.length,2)+4,this._config.deviceMaxTextureSize);this._tmpCanvas.width=t?2*t-o:t-o;!1==!(o>=t)||0===_?(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h+_,s),this._tmpCtx.lineTo(T,s)):(this._tmpCtx.setLineDash([Math.round(t),Math.round(t)]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(h+_,s),this._tmpCtx.moveTo(h+_+t,s),this._tmpCtx.lineTo(T,s)),o=(0,a.computeNextVariantOffset)(T-h,t,o);break;case 5:const Q=.6,u=.3,p=T-h,g=Math.floor(Q*p),M=Math.floor(u*p),y=p-g-M;this._tmpCtx.setLineDash([g,M,y]),this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s);break;default:this._tmpCtx.moveTo(h,s),this._tmpCtx.lineTo(T,s)}n||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!O&&this._config.fontSize>=12&&!this._config.allowTransparency&&" "!==T){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";const e=this._tmpCtx.measureText(T);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in e&&e.actualBoundingBoxDescent>0){this._tmpCtx.save();const e=new Path2D;e.rect(i,L-Math.ceil(t/2),this._config.deviceCellWidth*I,l-L+Math.ceil(t/2)),this._tmpCtx.clip(e),this._tmpCtx.lineWidth=3*this._config.devicePixelRatio,this._tmpCtx.strokeStyle=E.css,this._tmpCtx.strokeText(T,S,S+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(C){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),e=t%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+e),this._tmpCtx.stroke()}if(O||this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight),"_"===T&&!this._config.allowTransparency){let t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W);if(t)for(let e=1;e<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=E.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(T,S,S+this._config.deviceCharHeight-e),t=u(this._tmpCtx.getImageData(S,S,this._config.deviceCellWidth,this._config.deviceCellHeight),E,D,W),t);e++);}if(f){const t=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),e=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=t,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(S,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.lineTo(S+this._config.deviceCharWidth*I,S+Math.floor(this._config.deviceCharHeight/2)-e),this._tmpCtx.stroke()}this._tmpCtx.restore();const N=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height);let P;if(P=this._config.allowTransparency?function(t){for(let e=0;e0)return!1;return!0}(N):u(N,E,D,W),P)return d;const B=this._findGlyphBoundingBox(N,this._workBoundingBox,l,Z,O,S);let $,F;for(;;){if(0===this._activePages.length){const t=this._createNewPage();$=t,F=t.currentRow,F.height=B.size.y;break}$=this._activePages[this._activePages.length-1],F=$.currentRow;for(const t of this._activePages)B.size.y<=t.currentRow.height&&($=t,F=t.currentRow);for(let t=this._activePages.length-1;t>=0;t--)for(const e of this._activePages[t].fixedRows)e.height<=F.height&&B.size.y<=e.height&&($=this._activePages[t],F=e);if(B.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new Q(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),$=this._overflowSizePage,F=this._overflowSizePage.currentRow,F.x+B.size.x>=$.canvas.width&&(F.x=0,F.y+=F.height,F.height=0);break}if(F.y+B.size.y>=$.canvas.height||F.height>B.size.y+2){let t=!1;if($.currentRow.y+$.currentRow.height+B.size.y>=$.canvas.height){let e;for(const t of this._activePages)if(t.currentRow.y+t.currentRow.height+B.size.y=_.maxAtlasPages&&F.y+B.size.y<=$.canvas.height&&F.height>=B.size.y&&F.x+B.size.x<=$.canvas.width)t=!0;else{const e=this._createNewPage();$=e,F=e.currentRow,F.height=B.size.y,t=!0}}t||($.currentRow.height>0&&$.fixedRows.push($.currentRow),F={x:0,y:$.currentRow.y+$.currentRow.height,height:B.size.y},$.fixedRows.push(F),$.currentRow={x:0,y:F.y+F.height,height:0})}if(F.x+B.size.x<=$.canvas.width)break;F===$.currentRow?(F.x=0,F.y+=F.height,F.height=0):$.fixedRows.splice($.fixedRows.indexOf(F),1)}return B.texturePage=this._pages.indexOf($),B.texturePosition.x=F.x,B.texturePosition.y=F.y,B.texturePositionClipSpace.x=F.x/$.canvas.width,B.texturePositionClipSpace.y=F.y/$.canvas.height,B.sizeClipSpace.x/=$.canvas.width,B.sizeClipSpace.y/=$.canvas.height,F.height=Math.max(F.height,B.size.y),F.x+=B.size.x,$.ctx.putImageData(N,B.texturePosition.x-this._workBoundingBox.left,B.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,B.size.x,B.size.y),$.addGlyph(B),$.version=++Q.nextVersion,B}_findGlyphBoundingBox(t,e,i,s,r,a){e.top=0;const o=s?this._config.deviceCellHeight:this._tmpCanvas.height,n=s?this._config.deviceCellWidth:i;let L=!1;for(let i=0;i=a;i--){for(let s=0;s=0;i--){for(let s=0;s>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=i.rgba>>>24,L=i.rgba>>>16&255,h=i.rgba>>>8&255,T=Math.floor((Math.abs(r-n)+Math.abs(a-L)+Math.abs(o-h))/12);let l=!0;for(let e=0;ethis._handleColorChange())),this._cellColorResolver=new s.CellColorResolver(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new _.LinkRenderLayer(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,d,this._themeService)],this.dimensions=(0,g.createRenderDimensions)(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(d.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new T.TextBlinkStateManager(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextlost",t=>{this._logService.debug("webglcontextlost event received"),t.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(t)},3e3)})),this._register((0,u.addDisposableListener)(this._canvas,"webglcontextrestored",t=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,(0,r.removeTerminalFromCache)(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,this._coreBrowserService.window,(t,e)=>this._setCanvasDevicePixelDimensions(t,e)),this._register(this._coreBrowserService.onWindowChange(t=>{this._observerDisposable.value=(0,o.observeDevicePixelDimensions)(this._canvas,t,(t,e)=>this._setCanvasDevicePixelDimensions(t,e))})),this._register((0,u.addDisposableListener)(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register((0,p.toDisposable)(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(const t of this._renderLayers)t.dispose();this._canvas.parentElement?.removeChild(this._canvas),(0,r.removeTerminalFromCache)(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,e){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(const t of this._renderLayers)t.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(const t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(const t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,e,i){for(const s of this._renderLayers)s.handleSelectionChanged(this._terminal,t,e,i);this._model.selection.update(this._core,t,e,i),this._requestRedrawViewport()}handleCursorMove(){for(const t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new d.RectangleRenderer(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new l.GlyphRenderer(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0)return void(this._isAttached=!1);const t=(0,r.acquireTextureAtlas)(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=(0,p.combinedDisposable)(Q.EventUtils.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Q.EventUtils.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(const t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,e){if(!this._isAttached){if(!(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height))return;this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0}for(const i of this._renderLayers)i.handleGridChanged(this._terminal,t,e);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,e);let i=!1,s=0;for(;this._charAtlas&&s++<32&&this._glyphRenderer.value.beginFrame();)i=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);i&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),this._cursorBlinkStateManager.value&&!this._cursorBlinkStateManager.value.isCursorVisible||this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new a.CursorBlinkStateManager(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,e){const i=this._core;let s,r,a,o,n,L,T,l,d,_,Q,u,p,g,M,f,C,m=this._workCell;t=v(t,i.rows-1,0),e=v(e,i.rows-1,0);const w=this._coreService.decPrivateModes.cursorStyle??i.options.cursorStyle??"block",x=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,b=x-i.buffer.ydisp,E=Math.min(this._terminal.buffer.active.cursorX,i.cols-1);let R=-1;const k=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let A=!1;for(r=t;r<=e;r++){a=r+i.buffer.ydisp;const t=i.buffer.lines.get(a);if(!t){for(this._model.lineLengths[r]=0,f=0;f=T,d=f,n.length>0&&f===n[0][0]&&l){_=n.shift();const t=this._model.selection.isCellSelected(this._terminal,_[0],a);for(M=_[0]+1;M<_[1];M++)l&&=t===this._model.selection.isCellSelected(this._terminal,M,a);l&&=!Q||E<_[0]||E>=_[1],l?(L=!0,m=new y(m,o.translateToString(!0,_[0],_[1]),_[1]-_[0]),d=_[1]-1):T=_[1]}if(u=m.getChars(),p=m.getCode(),M=4*(r*i.cols+f),!e&&m.isBlink()&&(e=!0),this._cellColorResolver.resolve(m,f,a,this.dimensions.device.cell.width,this.dimensions.device.cell.height),k&&a===x&&(f===E&&(this._model.cursor={x:E,y:b,width:m.getWidth(),style:this._coreBrowserService.isFocused?w:i.options.cursorInactiveStyle,cursorWidth:i.options.cursorWidth,dpr:this._devicePixelRatio},R=E+m.getWidth()-1),f>=E&&f<=R&&(this._coreBrowserService.isFocused&&"block"===w||!1===this._coreBrowserService.isFocused&&"block"===i.options.cursorInactiveStyle)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&m.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),p!==h.NULL_CELL_CODE&&(this._model.lineLengths[r]=f+1),(this._model.cells[M]!==p||this._model.cells[M+1]!==this._cellColorResolver.result.bg||this._model.cells[M+2]!==this._cellColorResolver.result.fg||this._model.cells[M+3]!==this._cellColorResolver.result.ext)&&(A=!0,u.length>1&&(p|=c.COMBINED_CHAR_BIT_MASK),this._model.cells[M]=p,this._model.cells[M+1]=this._cellColorResolver.result.bg,this._model.cells[M+2]=this._cellColorResolver.result.fg,this._model.cells[M+3]=this._cellColorResolver.result.ext,g=m.getWidth(),this._glyphRenderer.value.updateCell(f,r,p,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,u,g,s),L)){for(m=this._workCell,f++;f<=d;f++)C=4*(r*i.cols+f),this._nullModelCell(f,r,C,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);f--}}this._setRowBlinkState(r,e)}A&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,e,i,s,r,a){this._glyphRenderer.value.updateCell(t,e,h.NULL_CELL_CODE,s,r,a,h.NULL_CELL_CHAR,0,0),this._model.cells[i]=h.NULL_CELL_CODE,this._model.cells[i+1]=s,this._model.cells[i+2]=r,this._model.cells[i+3]=a}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,e){this._rowHasBlinkingCells[t]!==e&&(this._rowHasBlinkingCells[t]=e,this._rowHasBlinkingCellsCount+=e?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){this._charSizeService.width&&this._charSizeService.height&&(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=1===this._optionsService.rawOptions.lineHeight?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,e){this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){const t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}}e.WebglRenderer=M;class y extends n.AttributeData{constructor(t,e,i){super(),this.content=0,this.combinedData="",this.fg=t.fg,this.bg=t.bg,this.combinedData=e,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}function v(t,e,i=0){return Math.max(Math.min(t,e),i)}e.JoinedCellData=y},719(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.GLTexture=e.PROJECTION_MATRIX=void 0,e.createProgram=function(t,e,i,a){const o=(0,s.throwIfFalsy)(t.createProgram());if(t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.VERTEX_SHADER,e,a))),t.attachShader(o,(0,s.throwIfFalsy)(r(t,t.FRAGMENT_SHADER,i,a))),t.linkProgram(o),t.getProgramParameter(o,t.LINK_STATUS))return o;a.error(t.getProgramInfoLog(o)),t.deleteProgram(o)},e.createShader=r,e.expandFloat32Array=function(t,e){const i=Math.min(2*t.length,e),s=new Float32Array(i);for(let e=0;e{const e=[],i=[3/8,2/8,3/8],s=[0,3/8,5/8];for(let r=0;r<3;r++){const a=t>>2*r&1,o=t>>2*r+1&1;a&&o?e.push(`M0,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):a?e.push(`M0,${s[r]} L0.5,${s[r]} L0.5,${s[r]+i[r]} L0,${s[r]+i[r]} Z`):o&&e.push(`M0.5,${s[r]} L1,${s[r]} L1,${s[r]+i[r]} L0.5,${s[r]+i[r]} Z`)}return e.join(" ")}}}function r(t){const e=[],i=.15,s=.015,r=.075,a=.0375,o=.95;if(64&t){const t=.175,a=.2125,n=.25,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(32&t){const t=o-i,s=.875,r=o,n=.265,L=.485;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(16&t){const t=o-i,s=.875,r=o,n=.515,L=.735;e.push(`M${s},${n} L${r},${n+a} L${r},${L-a} L${s},${L} L${t},${L-a} L${t},${n+a} Z`)}if(8&t){const t=.75,a=.7875,n=.825,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}if(4&t){const t=.05,i=.125,s=.2,r=.515,o=.735;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(2&t){const t=.05,i=.125,s=.2,r=.265,o=.485;e.push(`M${i},${r} L${s},${r+a} L${s},${o-a} L${i},${o} L${t},${o-a} L${t},${r+a} Z`)}if(1&t){const t=.4625,a=.5,n=.5375,L=.2+s,h=o-i-s;e.push(`M${L},${a} L${L+r},${t} L${h-r},${t} L${h},${a} L${h-r},${n} L${L+r},${n} Z`)}return e.join(" ")}Object.defineProperty(e,"__esModule",{value:!0}),e.blockPatternCodepoints=e.customGlyphDefinitions=void 0,function(t){t.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),t.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),t.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),t.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),t.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),t.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),t.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.CURVE_UP_RIGHT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1}),t.CURVE_UP_LEFT=Object.freeze({type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1}),t.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),t.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),t.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),t.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),t.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),t.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})}(i||(i={})),e.customGlyphDefinitions={"─":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"━":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"│":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"┃":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"┄":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"┅":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"┆":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"┇":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"┈":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"┉":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"┊":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"┋":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"┌":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"┍":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┎":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┏":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"┐":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"┑":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┒":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┓":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"└":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"┕":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┖":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┗":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"┘":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"┙":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┚":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┛":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"├":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"┝":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┞":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┟":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┠":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┡":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┢":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┣":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"┤":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"┥":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┦":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┧":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┨":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"┩":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┪":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┫":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"┬":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"┭":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┮":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┯":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┰":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"┱":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"┲":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"┳":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"┴":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"┵":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┶":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┷":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"┸":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"┹":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"┺":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"┻":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"┼":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"┽":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"┾":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"┿":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"╀":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"╁":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╂":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"╃":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"╄":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"╅":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"╆":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"╇":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"╈":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"╉":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"╊":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"╋":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"╌":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"╍":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"╎":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"╏":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"═":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"║":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╒":{type:2,data:(t,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╓":{type:2,data:(t,e)=>`M${.5-t},1 L${.5-t},.5 L1,.5 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╔":{type:2,data:(t,e)=>`M1,${.5-e} L${.5-t},${.5-e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╕":{type:2,data:(t,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╖":{type:2,data:(t,e)=>`M${.5+t},1 L${.5+t},.5 L0,.5 M${.5-t},.5 L${.5-t},1`,strokeWidth:1},"╗":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5+t},${.5-e} L${.5+t},1`,strokeWidth:1},"╘":{type:2,data:(t,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"╙":{type:2,data:(t,e)=>`M1,.5 L${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╚":{type:2,data:(t,e)=>`M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0 M1,${.5+e} L${.5-t},${.5+e} L${.5-t},0`,strokeWidth:1},"╛":{type:2,data:(t,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"╜":{type:2,data:(t,e)=>`M0,.5 L${.5+t},.5 L${.5+t},0 M${.5-t},.5 L${.5-t},0`,strokeWidth:1},"╝":{type:2,data:(t,e)=>`M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M0,${.5+e} L${.5+t},${.5+e} L${.5+t},0`,strokeWidth:1},"╞":{type:2,data:(t,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"╟":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1 M${.5+t},.5 L1,.5`,strokeWidth:1},"╠":{type:2,data:(t,e)=>`M${.5-t},0 L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╡":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"╢":{type:2,data:(t,e)=>`M0,.5 L${.5-t},.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╣":{type:2,data:(t,e)=>`M${.5+t},0 L${.5+t},1 M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0`,strokeWidth:1},"╤":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"╥":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},1 M${.5+t},.5 L${.5+t},1`,strokeWidth:1},"╦":{type:2,data:(t,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1`,strokeWidth:1},"╧":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╨":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},.5 L${.5-t},0 M${.5+t},.5 L${.5+t},0`,strokeWidth:1},"╩":{type:2,data:(t,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╪":{type:2,data:(t,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"╫":{type:2,data:(t,e)=>`M0,.5 L1,.5 M${.5-t},0 L${.5-t},1 M${.5+t},0 L${.5+t},1`,strokeWidth:1},"╬":{type:2,data:(t,e)=>`M0,${.5+e} L${.5-t},${.5+e} L${.5-t},1 M1,${.5+e} L${.5+t},${.5+e} L${.5+t},1 M0,${.5-e} L${.5-t},${.5-e} L${.5-t},0 M1,${.5-e} L${.5+t},${.5-e} L${.5+t},0`,strokeWidth:1},"╭":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╮":{type:2,data:(t,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╯":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"╰":{type:2,data:(t,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"╱":{type:2,data:"M1,0 L0,1",strokeWidth:1},"╲":{type:2,data:"M0,0 L1,1",strokeWidth:1},"╳":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"╴":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"╵":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"╶":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"╷":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"╸":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"╹":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"╺":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"╻":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"╼":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"╽":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"╾":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"╿":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"▀":{type:0,data:[{x:0,y:0,w:8,h:4}]},"▁":{type:0,data:[{x:0,y:7,w:8,h:1}]},"▂":{type:0,data:[{x:0,y:6,w:8,h:2}]},"▃":{type:0,data:[{x:0,y:5,w:8,h:3}]},"▄":{type:0,data:[{x:0,y:4,w:8,h:4}]},"▅":{type:0,data:[{x:0,y:3,w:8,h:5}]},"▆":{type:0,data:[{x:0,y:2,w:8,h:6}]},"▇":{type:0,data:[{x:0,y:1,w:8,h:7}]},"█":{type:0,data:[{x:0,y:0,w:8,h:8}]},"▉":{type:0,data:[{x:0,y:0,w:7,h:8}]},"▊":{type:0,data:[{x:0,y:0,w:6,h:8}]},"▋":{type:0,data:[{x:0,y:0,w:5,h:8}]},"▌":{type:0,data:[{x:0,y:0,w:4,h:8}]},"▍":{type:0,data:[{x:0,y:0,w:3,h:8}]},"▎":{type:0,data:[{x:0,y:0,w:2,h:8}]},"▏":{type:0,data:[{x:0,y:0,w:1,h:8}]},"▐":{type:0,data:[{x:4,y:0,w:4,h:8}]},"░":{type:1,data:[[1,0],[0,0]]},"▒":{type:1,data:[[1,0],[0,1]]},"▓":{type:1,data:[[1,1],[1,0]]},"▔":{type:0,data:[{x:0,y:0,w:8,h:1}]},"▕":{type:0,data:[{x:7,y:0,w:1,h:8}]},"▖":{type:0,data:[{x:0,y:4,w:4,h:4}]},"▗":{type:0,data:[{x:4,y:4,w:4,h:4}]},"▘":{type:0,data:[{x:0,y:0,w:4,h:4}]},"▙":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"▚":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"▛":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"▜":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"▝":{type:0,data:[{x:4,y:0,w:4,h:4}]},"▞":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"▟":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"":i.LINE_H,"":i.LINE_V,"":i.FADE_RIGHT,"":i.FADE_LEFT,"":i.FADE_DOWN,"":i.FADE_UP,"":i.CURVE_DOWN_RIGHT,"":i.CURVE_DOWN_LEFT,"":i.CURVE_UP_RIGHT,"":i.CURVE_UP_LEFT,"":[i.LINE_V,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT],"":[i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT],"":[i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_UP_RIGHT],"":[i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_UP_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_DOWN_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_RIGHT],"":[i.LINE_V,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_V,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.LINE_H,i.CURVE_DOWN_RIGHT,i.CURVE_UP_LEFT],"":[i.LINE_H,i.CURVE_DOWN_LEFT,i.CURVE_UP_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE],"":i.NODE_STROKE,"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_STROKE,i.NODE_LINE_DOWN],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_UP],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_FILL,i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"":[i.NODE_STROKE,i.NODE_LINE_UP,i.NODE_LINE_DOWN,i.NODE_LINE_LEFT,i.NODE_LINE_RIGHT],"🬀":s(1),"🬁":s(2),"🬂":s(3),"🬃":s(4),"🬄":s(5),"🬅":s(6),"🬆":s(7),"🬇":s(8),"🬈":s(9),"🬉":s(10),"🬊":s(11),"🬋":s(12),"🬌":s(13),"🬍":s(14),"🬎":s(15),"🬏":s(16),"🬐":s(17),"🬑":s(18),"🬒":s(19),"🬓":s(20),"🬔":s(22),"🬕":s(23),"🬖":s(24),"🬗":s(25),"🬘":s(26),"🬙":s(27),"🬚":s(28),"🬛":s(29),"🬜":s(30),"🬝":s(31),"🬞":s(32),"🬟":s(33),"🬠":s(34),"🬡":s(35),"🬢":s(36),"🬣":s(37),"🬤":s(38),"🬥":s(39),"🬦":s(40),"🬧":s(41),"🬨":s(43),"🬩":s(44),"🬪":s(45),"🬫":s(46),"🬬":s(47),"🬭":s(48),"🬮":s(49),"🬯":s(50),"🬰":s(51),"🬱":s(52),"🬲":s(53),"🬳":s(54),"🬴":s(55),"🬵":s(56),"🬶":s(57),"🬷":s(58),"🬸":s(59),"🬹":s(60),"🬺":s(61),"🬻":s(62),"🬼":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"🬽":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"🬾":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"🬿":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"🭀":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"🭁":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭂":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"🭃":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"🭄":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"🭅":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"🭆":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"🭇":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"🭈":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"🭉":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"🭊":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"🭋":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"🭌":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"🭍":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"🭎":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"🭏":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"🭐":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"🭑":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"🭒":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭓":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"🭔":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"🭕":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"🭖":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"🭗":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"🭘":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"🭙":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"🭚":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"🭛":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"🭜":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"🭝":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"🭞":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"🭟":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"🭠":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"🭡":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"🭢":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"🭣":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"🭤":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"🭥":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"🭦":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"🭧":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"🭨":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"🭩":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"🭪":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"🭫":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"🭬":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"🭭":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"🭮":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"🭯":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"🭰":{type:0,data:[{x:1,y:0,w:1,h:8}]},"🭱":{type:0,data:[{x:2,y:0,w:1,h:8}]},"🭲":{type:0,data:[{x:3,y:0,w:1,h:8}]},"🭳":{type:0,data:[{x:4,y:0,w:1,h:8}]},"🭴":{type:0,data:[{x:5,y:0,w:1,h:8}]},"🭵":{type:0,data:[{x:6,y:0,w:1,h:8}]},"🭶":{type:0,data:[{x:0,y:1,w:8,h:1}]},"🭷":{type:0,data:[{x:0,y:2,w:8,h:1}]},"🭸":{type:0,data:[{x:0,y:3,w:8,h:1}]},"🭹":{type:0,data:[{x:0,y:4,w:8,h:1}]},"🭺":{type:0,data:[{x:0,y:5,w:8,h:1}]},"🭻":{type:0,data:[{x:0,y:6,w:8,h:1}]},"🭼":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🭽":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭾":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"🭿":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"🮀":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮁":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"🮂":{type:0,data:[{x:0,y:0,w:8,h:2}]},"🮃":{type:0,data:[{x:0,y:0,w:8,h:3}]},"🮄":{type:0,data:[{x:0,y:0,w:8,h:5}]},"🮅":{type:0,data:[{x:0,y:0,w:8,h:6}]},"🮆":{type:0,data:[{x:0,y:0,w:8,h:7}]},"🮇":{type:0,data:[{x:6,y:0,w:2,h:8}]},"🮈":{type:0,data:[{x:5,y:0,w:3,h:8}]},"🮉":{type:0,data:[{x:3,y:0,w:5,h:8}]},"🮊":{type:0,data:[{x:2,y:0,w:6,h:8}]},"🮋":{type:0,data:[{x:1,y:0,w:7,h:8}]},"🮌":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"🮍":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"🮎":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"🮏":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"🮐":{type:1,data:[[0,1],[1,0]]},"🮑":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"🮒":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"🮔":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"🮕":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"🮖":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"🮗":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"🮘":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"🮙":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"🮚":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"🮛":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"🮜":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"🮝":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"🮞":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"🮟":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"🮠":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"🮡":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"🮢":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"🮣":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"🮤":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"🮥":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"🮦":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"🮧":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"🮨":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"🮩":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"🮪":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"🮫":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"🮬":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"🮭":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"🮮":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"🮯":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"🮰":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"🮱":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"🮲":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"🮳":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"🮴":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"🮵":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮶":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"🮷":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮸":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"🮹":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"🮺":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"🮻":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"🮼":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"🮽":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"🮾":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"🮿":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"🯀":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"🯁":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"🯂":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"🯃":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"🯄":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"🯅":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯆":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯇":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯈":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯉":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"🯊":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"🯋":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"🯌":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"🯍":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"🯎":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"🯏":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"🯐":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"🯑":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"🯒":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"🯓":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"🯔":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"🯕":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"🯖":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"🯗":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"🯘":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"🯙":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"🯚":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"🯛":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"🯜":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"🯝":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"🯞":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"🯟":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"🯠":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"🯡":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"🯢":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"🯣":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"🯤":{type:0,data:[{x:2,y:0,w:4,h:4}]},"🯥":{type:0,data:[{x:2,y:4,w:4,h:4}]},"🯦":{type:0,data:[{x:0,y:2,w:4,h:4}]},"🯧":{type:0,data:[{x:4,y:2,w:4,h:4}]},"🯨":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"🯩":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"🯪":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"🯫":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"🯬":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"🯭":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"🯮":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"🯯":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"🯰":{type:5,data:{d:r(126),type:0},scaleType:1},"🯱":{type:5,data:{d:r(48),type:0},scaleType:1},"🯲":{type:5,data:{d:r(109),type:0},scaleType:1},"🯳":{type:5,data:{d:r(121),type:0},scaleType:1},"🯴":{type:5,data:{d:r(51),type:0},scaleType:1},"🯵":{type:5,data:{d:r(91),type:0},scaleType:1},"🯶":{type:5,data:{d:r(95),type:0},scaleType:1},"🯷":{type:5,data:{d:r(114),type:0},scaleType:1},"🯸":{type:5,data:{d:r(127),type:0},scaleType:1},"🯹":{type:5,data:{d:r(123),type:0},scaleType:1},"🯺":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(t,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},e.blockPatternCodepoints=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951])},183(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.tryDrawCustomGlyph=function(t,e,i,s,o,n,L,h,T,l,d,c,_=0){const Q=r.customGlyphDefinitions[e];if(Q){const e=Array.isArray(Q)?Q:[Q];for(const r of e)a(t,r,i,s,o,n,L,h,T,l,d,c,_);return!0}return!1};const s=i(959),r=i(915);function a(t,e,i,r,a,h,d,c,_,Q,u,p,g=0){let M=a,y=h,v=i,f=r;switch(1===e.scaleType&&(M=d,y=c,v=i+(a-d)/2,f=r+(h-c)/2),e.clipPath&&(t.save(),function(t,e,i,s,r,a){t.beginPath();for(const o of e.split(" ")){const e=o[0];if("Z"===e){t.closePath();continue}const n=o.substring(1).split(",");if(!n[0]||!n[1])continue;const L=i+parseFloat(n[0])*r,h=s+parseFloat(n[1])*a;"M"===e?t.moveTo(L,h):"L"===e&&t.lineTo(L,h)}t.clip()}(t,e.clipPath,v,f,M,y)),e.type){case 0:!function(t,e,i,s,r,a){for(let o=0;o7&&parseInt(T.slice(7,9),16)||1;else{if(!T.startsWith("rgba"))throw new Error(`Unexpected fillStyle color format "${T}" when drawing pattern glyph`);[L,d,c,_]=T.substring(5,T.length-1).split(",").map(t=>parseFloat(t))}for(let t=0;t>1&1,c=1&n;0!==d||0!==c?l.setTransform((new DOMMatrix).translateSelf(-d,-c)):l.setTransform(new DOMMatrix),t.fillStyle=l,t.fillRect(i,r,a,o)}(t,e.data,v,f,M,y,g);break;case 2:!function(t,e,i,s,r,a,o,n,L){let h;t.save(),t.beginPath(),t.rect(i,s,r,a),t.clip(),t.beginPath(),h="function"==typeof e?e(.15,.15/a*r):e;const d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const e of h.split(" ")){const L=e[0];if("Z"===L){t.closePath(),d.lastCommand=L;continue}const h=T[L];if(!h){n.error(`Could not find drawing instructions for "${L}"`);continue}const c=e.substring(1).split(",");c[0]&&c[1]&&(h(t,l(c,r,a,i,s,!0,o,0,0,!1),d),d.lastCommand=L)}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill(),t.closePath(),t.restore()}(t,e.data,v,f,M,y,Q,u,e.strokeWidth);break;case 3:!function(t,e,i,s,r,a,o,L){const h="string"==typeof e?e:e(0,0);t.beginPath();let T=0,l=0,d=0,c=0,_="";for(const e of h.split(" ")){const o=e[0],L=e.substring(1).split(",");if("Z"===o){t.closePath(),_=o;continue}if("V"===o){const e=s+parseFloat(L[0])*a;t.lineTo(T,e),l=e,d=T,c=l,_=o;continue}if("H"===o){const e=i+parseFloat(L[0])*r;t.lineTo(e,l),T=e,d=T,c=l,_=o;continue}if(!L[0]||!L[1])continue;if("A"===o){const e=parseFloat(L[0])*r,o=parseFloat(L[1])*a,h=parseFloat(L[2])*Math.PI/180,d=parseInt(L[3],10),c=parseInt(L[4],10),_=i+parseFloat(L[5])*r,Q=s+parseFloat(L[6])*a;n(t,T,l,e,o,h,d,c,_,Q),T=_,l=Q;continue}const h=L.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});if("M"===o)t.moveTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("L"===o)t.lineTo(h[0],h[1]),T=h[0],l=h[1],d=T,c=l;else if("Q"===o)t.quadraticCurveTo(h[0],h[1],h[2],h[3]),d=h[0],c=h[1],T=h[2],l=h[3];else if("T"===o){let e,i;"Q"===_||"T"===_?(e=2*T-d,i=2*l-c):(e=T,i=l),t.quadraticCurveTo(e,i,h[0],h[1]),d=e,c=i,T=h[0],l=h[1]}else"C"===o&&(t.bezierCurveTo(h[0],h[1],h[2],h[3],h[4],h[5]),d=h[2],c=h[3],T=h[4],l=h[5]);_=o}void 0!==L?(t.strokeStyle=t.fillStyle,t.lineWidth=o*L,t.stroke()):t.fill()}(t,e.data,v,f,M,y,Q,e.strokeWidth);break;case 4:!function(t,e,i,s,r,a,o,n){t.save(),t.fillRect(i,s,r,a),n&&(t.fillStyle=n,t.strokeStyle=n),t.lineWidth=o,t.lineCap="square",t.beginPath();for(const o of e.d.split(" ")){const e=o[0],n=o.substring(1).split(",");if(!n[0]||!n[1]){"Z"===e&&t.closePath();continue}const L=n.map((t,e)=>{const o=parseFloat(t);return e%2==0?i+o*r:s+o*a});"M"===e?t.moveTo(L[0],L[1]):"L"===e&&t.lineTo(L[0],L[1])}1===e.type?t.stroke():t.fill(),t.restore()}(t,e.data,v,f,M,y,Q,p);break;case 5:!function(t,e,i,s,r,a,o,n,L){const h=new Path2D;h.rect(i,s,r,a),t.clip(h),t.beginPath();const d=o/12;t.lineWidth=n*d;const c={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(const o of e.d.split(" ")){const h=o[0];if("Z"===h){t.closePath(),c.lastCommand=h;continue}const _=T[h];if(!_){L.error(`Could not find drawing instructions for "${h}"`);continue}const Q=o.substring(1).split(",");Q[0]&&Q[1]&&(_(t,l(Q,r,a,i,s,!1,n,(e.leftPadding??0)*(d/2),(e.rightPadding??0)*(d/2)),c),c.lastCommand=h)}1===e.type?(t.strokeStyle=t.fillStyle,t.stroke()):t.fill(),t.closePath()}(t,e.data,v,f,M,y,_,Q,u);break;case 6:!function(t,e,i,s,r,a){const n=r/8,L=.1*a,h=.8*a/8,T=Math.min(n,h);for(let r=0;r<8;r++)if(e&1<1){const t=Math.sqrt(y);u=(s*=t)*s,p=(r*=t)*r}let v=(u*p-u*M-p*g)/(u*M+p*g);v<0&&(v=0);const f=(o===n?-1:1)*Math.sqrt(v),C=f*(s*Q/r),m=f*(-r*_/s),w=T*C-l*m+(e+L)/2,x=l*C+T*m+(i+h)/2,b=(_-C)/s,E=(Q-m)/r,R=(-_-C)/s,k=(-Q-m)/r,A=Math.atan2(E,b);let Z=Math.atan2(k,R)-A;0===n&&Z>0?Z-=2*Math.PI:1===n&&Z<0&&(Z+=2*Math.PI);const D=A+Z;t.ellipse(w,x,s,r,a,A,D,0===n)}const L=new Map;function h(t,e,i=0){return Math.max(Math.min(t,e),i)}const T={C:(t,e,i)=>{t.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),i.lastControlX=e[2],i.lastControlY=e[3],i.currentX=e[4],i.currentY=e[5]},L:(t,e,i)=>{t.lineTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},M:(t,e,i)=>{t.moveTo(e[0],e[1]),i.lastControlX=i.currentX=e[0],i.lastControlY=i.currentY=e[1]},Q:(t,e,i)=>{t.quadraticCurveTo(e[0],e[1],e[2],e[3]),i.lastControlX=e[0],i.lastControlY=e[1],i.currentX=e[2],i.currentY=e[3]},T:(t,e,i)=>{let s,r;"Q"===i.lastCommand||"T"===i.lastCommand?(s=2*i.currentX-i.lastControlX,r=2*i.currentY-i.lastControlY):(s=i.currentX,r=i.currentY),t.quadraticCurveTo(s,r,e[0],e[1]),i.lastControlX=s,i.lastControlY=r,i.currentX=e[0],i.currentY=e[1]}};function l(t,e,i,s,r,a,o,n=0,L=0,T=!0){const l=t.map(t=>parseFloat(t)||parseInt(t));if(l.length<2)throw new Error("Too few arguments for instruction");for(let t=0;t{this._refreshCharAtlas(t,e),this.reset(t)})),this._register((0,r.toDisposable)(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=(0,a.throwIfFalsy)(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,e,i){}handleSelectionChanged(t,e,i,s=!1){}_setTransparency(t,e){if(e===this._alpha)return;const i=this._canvas;this._alpha=e,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,i),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,e){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=(0,s.acquireTextureAtlas)(t,this._optionsService.rawOptions,e,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,e){this._deviceCellWidth=e.device.cell.width,this._deviceCellHeight=e.device.cell.height,this._deviceCharWidth=e.device.char.width,this._deviceCharHeight=e.device.char.height,this._deviceCharLeft=e.device.char.left,this._deviceCharTop=e.device.char.top,this._canvas.width=e.device.canvas.width,this._canvas.height=e.device.canvas.height,this._canvas.style.width=`${e.css.canvas.width}px`,this._canvas.style.height=`${e.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,e,i=1){this._ctx.fillRect(t*this._deviceCellWidth,(e+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,i*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,e,i,s){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,s*this._deviceCellHeight))}_fillCharTrueColor(t,e,i,s){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=o.TEXT_BASELINE,this._clipCell(i,s,e.getWidth()),this._ctx.fillText(e.getChars(),i*this._deviceCellWidth+this._deviceCharLeft,s*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,e,i){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,e*this._deviceCellHeight,i*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,e,i){return`${i?"italic":""} ${e?t.options.fontWeightBold:t.options.fontWeight} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}}e.BaseRenderLayer=n},306(t,e,i){Object.defineProperty(e,"__esModule",{value:!0}),e.LinkRenderLayer=void 0;const s=i(657),r=i(814),a=i(133);class o extends a.BaseRenderLayer{constructor(t,e,i,s,r,a,o){super(i,t,"link",e,!0,r,a,o),this._register(s.onShowLinkUnderline(t=>this._handleShowLinkUnderline(t))),this._register(s.onHideLinkUnderline(t=>this._handleHideLinkUnderline(t)))}resize(t,e){super.resize(t,e),this._state=void 0}reset(t){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);const t=this._state.y2-this._state.y1-1;t>0&&this._clearCells(0,this._state.y1+1,this._state.cols,t),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(t){if(t.fg===r.INVERTED_DEFAULT_COLOR?this._ctx.fillStyle=this._themeService.colors.background.css:void 0!==t.fg&&(0,s.is256Color)(t.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[t.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,t.y1===t.y2)this._fillBottomLineAtCells(t.x1,t.y1,t.x2-t.x1);else{this._fillBottomLineAtCells(t.x1,t.y1,t.cols-t.x1);for(let e=t.y1+1;e{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.WebglAddon=void 0;const e=i(426),r=i(95),a=i(399),o=i(414);class n extends e.Disposable{constructor(t){if(r.isSafari&&(0,r.getSafariVersion)()<16){const t={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",t))throw new Error("WebGL2 is only supported on Safari 16 and above")}super(),this._onChangeTextureAtlas=this._register(new o.Emitter),this.onChangeTextureAtlas=this._onChangeTextureAtlas.event,this._onAddTextureAtlasCanvas=this._register(new o.Emitter),this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=this._register(new o.Emitter),this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._onContextLoss=this._register(new o.Emitter),this.onContextLoss=this._onContextLoss.event,this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){const i=t._core;if(!t.element)return void this._register(i.onWillOpen(()=>this.activate(t)));this._terminal=t;const s=i.coreService,r=i.optionsService,n=i,L=n._renderService,h=n._characterJoinerService,T=n._charSizeService,l=n._coreBrowserService,d=n._decorationService,c=n._logService,_=n._themeService;this._renderer=this._register(new a.WebglRenderer(t,h,T,l,s,d,c,r,_,this._customGlyphs,this._preserveDrawingBuffer)),this._register(o.EventUtils.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(o.EventUtils.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(o.EventUtils.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(o.EventUtils.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),L.setRenderer(this._renderer),this._register((0,e.toDisposable)(()=>{if(this._terminal._core._store._isDisposed)return;const e=this._terminal._core._renderService;e.setRenderer(this._terminal._core._createRenderer()),e.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}}t.WebglAddon=n})(),s})()); //# sourceMappingURL=addon-webgl.js.map \ No newline at end of file diff --git a/lib/addon-webgl.mjs b/lib/addon-webgl.mjs -index 73e0de80affdf478c39b7ac1d8666e34bd3d6e31..dc563e7c5f0d5ce00729ceabece415b886a7a2ff 100644 +index 73e0de80affdf478c39b7ac1d8666e34bd3d6e31..e0f3c25039e12dc0c9094b2d19aa628849c354ca 100644 --- a/lib/addon-webgl.mjs +++ b/lib/addon-webgl.mjs @@ -14,7 +14,7 @@ @@ -16,7 +16,7 @@ index 73e0de80affdf478c39b7ac1d8666e34bd3d6e31..dc563e7c5f0d5ce00729ceabece415b8 * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -function M(i){return{dispose:i}}function Ft(i){if(!i)return i;if(Array.isArray(i)){for(let e of i)e.dispose();return[]}return i.dispose(),i}function De(...i){return M(()=>Ft(i))}var R0=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(let e of this._disposables)e.dispose();this._disposables.clear()}},W=class{constructor(){this._store=new R0}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}};W.None=Object.freeze({dispose(){}});var m0=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var Pe=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),O0=Pe?"node":navigator.userAgent,ce=Pe?"node":navigator.platform,Be=O0.includes("Firefox"),n1=O0.includes("Chrome"),we=O0.includes("Edge"),ue=/^((?!chrome|android).)*safari/i.test(O0);function He(){if(!ue)return 0;let i=O0.match(/Version\/(\d+)/);return i===null||i.length<2?0:parseInt(i[1],10)}var a1=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(ce),o1=["Windows","Win16","Win32","WinCE"].includes(ce),T1=ce.indexOf("Linux")>=0,l1=/\bCrOS\b/.test(O0);var P=0,B=0,w=0,x=0,K={css:"#00000000",rgba:0},Z;(r=>{function i(s,a,o,n){return n!==void 0?`#${E0(s)}${E0(a)}${E0(o)}${E0(n)}`:`#${E0(s)}${E0(a)}${E0(o)}`}r.toCss=i;function e(s,a,o,n=255){return(s<<24|a<<16|o<<8|n)>>>0}r.toRgba=e;function t(s,a,o,n){return{css:r.toCss(s,a,o,n),rgba:r.toRgba(s,a,o,n)}}r.toColor=t})(Z||={});var I0;(n=>{function i(L,T){if(x=(T.rgba&255)/255,x===1)return{css:T.css,rgba:T.rgba};let h=T.rgba>>24&255,d=T.rgba>>16&255,_=T.rgba>>8&255,p=L.rgba>>24&255,c=L.rgba>>16&255,u=L.rgba>>8&255;P=p+Math.round((h-p)*x),B=c+Math.round((d-c)*x),w=u+Math.round((_-u)*x);let C=Z.toCss(P,B,w),Q=Z.toRgba(P,B,w);return{css:C,rgba:Q}}n.blend=i;function e(L){return(L.rgba&255)===255}n.isOpaque=e;function t(L,T,h){let d=d0.ensureContrastRatio(L.rgba,T.rgba,h);if(d)return Z.toColor(d>>24&255,d>>16&255,d>>8&255)}n.ensureContrastRatio=t;function r(L){let T=(L.rgba|255)>>>0;return[P,B,w]=d0.toChannels(T),{css:Z.toCss(P,B,w),rgba:T}}n.opaque=r;function s(L,T){return x=Math.round(T*255),[P,B,w]=d0.toChannels(L.rgba),{css:Z.toCss(P,B,w,x),rgba:Z.toRgba(P,B,w,x)}}n.opacity=s;function a(L,T){return x=L.rgba&255,s(L,x*T/255)}n.multiplyOpacity=a;function o(L){return[L.rgba>>24&255,L.rgba>>16&255,L.rgba>>8&255]}n.toColorRGB=o})(I0||={});var Mt;(r=>{let i,e;try{let s=document.createElement("canvas");s.width=1,s.height=1;let a=s.getContext("2d",{willReadFrequently:!0});a&&(i=a,i.globalCompositeOperation="copy",e=i.createLinearGradient(0,0,1,1))}catch{}function t(s){if(s.match(/#[\da-f]{3,8}/i))switch(s.length){case 4:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),Z.toColor(P,B,w);case 5:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),x=parseInt(s.slice(4,5).repeat(2),16),Z.toColor(P,B,w,x);case 7:return{css:s,rgba:(parseInt(s.slice(1),16)<<8|255)>>>0};case 9:return{css:s,rgba:parseInt(s.slice(1),16)>>>0}}let a=s.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return P=parseInt(a[1],10),B=parseInt(a[2],10),w=parseInt(a[3],10),x=Math.round((a[5]===void 0?1:parseFloat(a[5]))*255),Z.toColor(P,B,w,x);if(s==="transparent")return{css:"transparent",rgba:0};if(!i||!e)throw new Error("css.toColor: Unsupported css format");if(i.fillStyle=e,i.fillStyle=s,typeof i.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(i.fillRect(0,0,1,1),[P,B,w,x]=i.getImageData(0,0,1,1).data,x!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:Z.toRgba(P,B,w,x),css:s}}r.toColor=t})(Mt||={});var V;(t=>{function i(r){return e(r>>16&255,r>>8&255,r&255)}t.relativeLuminance=i;function e(r,s,a){let o=r/255,n=s/255,L=a/255,T=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),h=n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4),d=L<=.03928?L/12.92:Math.pow((L+.055)/1.055,2.4);return T*.2126+h*.7152+d*.0722}t.relativeLuminance2=e})(V||={});var d0;(a=>{function i(o,n){if(x=(n&255)/255,x===1)return n;let L=n>>24&255,T=n>>16&255,h=n>>8&255,d=o>>24&255,_=o>>16&255,p=o>>8&255;return P=d+Math.round((L-d)*x),B=_+Math.round((T-_)*x),w=p+Math.round((h-p)*x),Z.toRgba(P,B,w)}a.blend=i;function e(o,n,L){let T=V.relativeLuminance(o>>8),h=V.relativeLuminance(n>>8);if(h0(T,h)>8));if(u>8));return u>Q?c:C}return c}let _=r(o,n,L),p=h0(T,V.relativeLuminance(_>>8));if(p>8));return p>u?_:c}return _}}a.ensureContrastRatio=e;function t(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u0||p>0||c>0);)_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),c-=Math.max(0,Math.ceil(c*.1)),u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));return(_<<24|p<<16|c<<8|255)>>>0}a.reduceLuminance=t;function r(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u>>0}a.increaseLuminance=r;function s(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}a.toChannels=s})(d0||={});function E0(i){let e=i.toString(16);return e.length<2?"0"+e:e}function h0(i,e){return i=128512&&i<=128591||i>=127744&&i<=128511||i>=128640&&i<=128767||i>=9728&&i<=9983||i>=9984&&i<=10175||i>=65024&&i<=65039||i>=129280&&i<=129535||i>=127462&&i<=127487}function Ze(i,e,t,r){return e===1&&t>Math.ceil(r*1.5)&&i!==void 0&&i>255&&!yt(i)&&!W0(i)&&!At(i)}function V0(i){return W0(i)||gt(i)}function Ue(){return{css:{canvas:U0(),cell:U0()},device:{canvas:U0(),cell:U0(),char:{width:0,height:0,left:0,top:0}}}}function U0(){return{width:0,height:0}}function We(i,e,t=0){return(i-(Math.round(e)*2-t))%(Math.round(e)*2)}var l;(C=>(C.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),C.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),C.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),C.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),C.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),C.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),C.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.CURVE_UP_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_UP_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),C.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),C.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),C.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),C.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),C.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})))(l||={});var Ve={"\u2500":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"\u2501":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"\u2502":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"\u2503":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"\u2504":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"\u2505":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"\u2506":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"\u2507":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"\u2508":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"\u2509":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"\u250A":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"\u250B":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"\u250C":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"\u250D":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u250E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u250F":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"\u2510":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"\u2511":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2512":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2513":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"\u2514":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"\u2515":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2516":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2517":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"\u2518":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"\u2519":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u251A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251B":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"\u251C":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"\u251D":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u251E":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251F":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2520":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2521":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2522":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2523":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"\u2524":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"\u2525":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2526":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2527":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2528":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2529":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u252A":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u252B":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"\u252C":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"\u252D":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u252E":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u252F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2530":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2531":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2532":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2533":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"\u2534":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"\u2535":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2536":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2537":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2538":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2539":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u253A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u253B":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"\u253C":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"\u253D":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u253E":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u253F":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2540":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2541":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2542":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2543":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u2544":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2545":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2546":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2547":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"\u2548":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"\u2549":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"\u254A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"\u254B":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"\u254C":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"\u254D":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"\u254E":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"\u254F":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"\u2550":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2551":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2552":{type:2,data:(i,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2553":{type:2,data:(i,e)=>`M${.5-i},1 L${.5-i},.5 L1,.5 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2554":{type:2,data:(i,e)=>`M1,${.5-e} L${.5-i},${.5-e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2555":{type:2,data:(i,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2556":{type:2,data:(i,e)=>`M${.5+i},1 L${.5+i},.5 L0,.5 M${.5-i},.5 L${.5-i},1`,strokeWidth:1},"\u2557":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5+i},${.5-e} L${.5+i},1`,strokeWidth:1},"\u2558":{type:2,data:(i,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"\u2559":{type:2,data:(i,e)=>`M1,.5 L${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u255A":{type:2,data:(i,e)=>`M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0 M1,${.5+e} L${.5-i},${.5+e} L${.5-i},0`,strokeWidth:1},"\u255B":{type:2,data:(i,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"\u255C":{type:2,data:(i,e)=>`M0,.5 L${.5+i},.5 L${.5+i},0 M${.5-i},.5 L${.5-i},0`,strokeWidth:1},"\u255D":{type:2,data:(i,e)=>`M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M0,${.5+e} L${.5+i},${.5+e} L${.5+i},0`,strokeWidth:1},"\u255E":{type:2,data:(i,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u255F":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1 M${.5+i},.5 L1,.5`,strokeWidth:1},"\u2560":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u2561":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2562":{type:2,data:(i,e)=>`M0,.5 L${.5-i},.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2563":{type:2,data:(i,e)=>`M${.5+i},0 L${.5+i},1 M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0`,strokeWidth:1},"\u2564":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"\u2565":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},1 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2566":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2567":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2568":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u2569":{type:2,data:(i,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256A":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u256B":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u256C":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256D":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u256E":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u256F":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u2570":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u2571":{type:2,data:"M1,0 L0,1",strokeWidth:1},"\u2572":{type:2,data:"M0,0 L1,1",strokeWidth:1},"\u2573":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"\u2574":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"\u2575":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"\u2576":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"\u2577":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"\u2578":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"\u2579":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"\u257A":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"\u257B":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"\u257C":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u257D":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u257E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u257F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2580":{type:0,data:[{x:0,y:0,w:8,h:4}]},"\u2581":{type:0,data:[{x:0,y:7,w:8,h:1}]},"\u2582":{type:0,data:[{x:0,y:6,w:8,h:2}]},"\u2583":{type:0,data:[{x:0,y:5,w:8,h:3}]},"\u2584":{type:0,data:[{x:0,y:4,w:8,h:4}]},"\u2585":{type:0,data:[{x:0,y:3,w:8,h:5}]},"\u2586":{type:0,data:[{x:0,y:2,w:8,h:6}]},"\u2587":{type:0,data:[{x:0,y:1,w:8,h:7}]},"\u2588":{type:0,data:[{x:0,y:0,w:8,h:8}]},"\u2589":{type:0,data:[{x:0,y:0,w:7,h:8}]},"\u258A":{type:0,data:[{x:0,y:0,w:6,h:8}]},"\u258B":{type:0,data:[{x:0,y:0,w:5,h:8}]},"\u258C":{type:0,data:[{x:0,y:0,w:4,h:8}]},"\u258D":{type:0,data:[{x:0,y:0,w:3,h:8}]},"\u258E":{type:0,data:[{x:0,y:0,w:2,h:8}]},"\u258F":{type:0,data:[{x:0,y:0,w:1,h:8}]},"\u2590":{type:0,data:[{x:4,y:0,w:4,h:8}]},"\u2591":{type:1,data:[[1,0],[0,0]]},"\u2592":{type:1,data:[[1,0],[0,1]]},"\u2593":{type:1,data:[[1,1],[1,0]]},"\u2594":{type:0,data:[{x:0,y:0,w:8,h:1}]},"\u2595":{type:0,data:[{x:7,y:0,w:1,h:8}]},"\u2596":{type:0,data:[{x:0,y:4,w:4,h:4}]},"\u2597":{type:0,data:[{x:4,y:4,w:4,h:4}]},"\u2598":{type:0,data:[{x:0,y:0,w:4,h:4}]},"\u2599":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\u259A":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"\u259B":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"\u259C":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"\u259D":{type:0,data:[{x:4,y:0,w:4,h:4}]},"\u259E":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"\u259F":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\uE0A0":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"\uE0A1":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0A2":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"\uE0A3":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0B0":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"\uE0B1":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B2":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"\uE0B3":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B4":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"\uE0B5":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"\uE0B6":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"\uE0B7":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"\uE0B8":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"\uE0B9":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BA":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"\uE0BB":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BC":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"\uE0BD":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BE":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"\uE0BF":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0C0":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"\uE0C1":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"\uE0C2":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"\uE0C3":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"\uE0C4":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"\uE0C5":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"\uE0C6":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"\uE0C7":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"\uE0C8":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"\uE0CA":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"\uE0CC":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"\uE0CD":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"\uE0CE":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"\uE0CF":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"\uE0D0":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"\uE0D1":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"\uE0D2":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"\uE0D4":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"\uEE00":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"\uEE01":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"\uEE02":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"\uEE03":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"\uEE04":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"\uEE05":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"\uEE06":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"\uEE07":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"\uEE08":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE09":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0A":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0B":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"\uF5D0":l.LINE_H,"\uF5D1":l.LINE_V,"\uF5D2":l.FADE_RIGHT,"\uF5D3":l.FADE_LEFT,"\uF5D4":l.FADE_DOWN,"\uF5D5":l.FADE_UP,"\uF5D6":l.CURVE_DOWN_RIGHT,"\uF5D7":l.CURVE_DOWN_LEFT,"\uF5D8":l.CURVE_UP_RIGHT,"\uF5D9":l.CURVE_UP_LEFT,"\uF5DA":[l.LINE_V,l.CURVE_UP_RIGHT],"\uF5DB":[l.LINE_V,l.CURVE_DOWN_RIGHT],"\uF5DC":[l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5DD":[l.LINE_V,l.CURVE_UP_LEFT],"\uF5DE":[l.LINE_V,l.CURVE_DOWN_LEFT],"\uF5DF":[l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E0":[l.LINE_H,l.CURVE_DOWN_LEFT],"\uF5E1":[l.LINE_H,l.CURVE_DOWN_RIGHT],"\uF5E2":[l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E3":[l.LINE_H,l.CURVE_UP_LEFT],"\uF5E4":[l.LINE_H,l.CURVE_UP_RIGHT],"\uF5E5":[l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E6":[l.LINE_V,l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E7":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E8":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E9":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5EA":[l.LINE_V,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5EB":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EC":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5ED":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EE":[l.NODE_FILL,l.NODE_STROKE],"\uF5EF":l.NODE_STROKE,"\uF5F0":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F1":[l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F2":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F3":[l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F4":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F5":[l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F6":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F7":[l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F8":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP],"\uF5F9":[l.NODE_STROKE,l.NODE_LINE_UP],"\uF5FA":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FB":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FC":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FD":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FE":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF5FF":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF600":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF601":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF602":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF603":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF604":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF605":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF606":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF607":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF608":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF609":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60A":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60B":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60C":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60D":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\u{1FB00}":f(1),"\u{1FB01}":f(2),"\u{1FB02}":f(3),"\u{1FB03}":f(4),"\u{1FB04}":f(5),"\u{1FB05}":f(6),"\u{1FB06}":f(7),"\u{1FB07}":f(8),"\u{1FB08}":f(9),"\u{1FB09}":f(10),"\u{1FB0A}":f(11),"\u{1FB0B}":f(12),"\u{1FB0C}":f(13),"\u{1FB0D}":f(14),"\u{1FB0E}":f(15),"\u{1FB0F}":f(16),"\u{1FB10}":f(17),"\u{1FB11}":f(18),"\u{1FB12}":f(19),"\u{1FB13}":f(20),"\u{1FB14}":f(22),"\u{1FB15}":f(23),"\u{1FB16}":f(24),"\u{1FB17}":f(25),"\u{1FB18}":f(26),"\u{1FB19}":f(27),"\u{1FB1A}":f(28),"\u{1FB1B}":f(29),"\u{1FB1C}":f(30),"\u{1FB1D}":f(31),"\u{1FB1E}":f(32),"\u{1FB1F}":f(33),"\u{1FB20}":f(34),"\u{1FB21}":f(35),"\u{1FB22}":f(36),"\u{1FB23}":f(37),"\u{1FB24}":f(38),"\u{1FB25}":f(39),"\u{1FB26}":f(40),"\u{1FB27}":f(41),"\u{1FB28}":f(43),"\u{1FB29}":f(44),"\u{1FB2A}":f(45),"\u{1FB2B}":f(46),"\u{1FB2C}":f(47),"\u{1FB2D}":f(48),"\u{1FB2E}":f(49),"\u{1FB2F}":f(50),"\u{1FB30}":f(51),"\u{1FB31}":f(52),"\u{1FB32}":f(53),"\u{1FB33}":f(54),"\u{1FB34}":f(55),"\u{1FB35}":f(56),"\u{1FB36}":f(57),"\u{1FB37}":f(58),"\u{1FB38}":f(59),"\u{1FB39}":f(60),"\u{1FB3A}":f(61),"\u{1FB3B}":f(62),"\u{1FB3C}":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"\u{1FB3D}":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"\u{1FB3E}":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"\u{1FB3F}":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"\u{1FB40}":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"\u{1FB41}":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB42}":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"\u{1FB43}":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB44}":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"\u{1FB45}":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"\u{1FB46}":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"\u{1FB47}":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"\u{1FB48}":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"\u{1FB49}":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"\u{1FB4A}":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"\u{1FB4B}":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"\u{1FB4C}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4D}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4E}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB4F}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB50}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"\u{1FB51}":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB52}":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB53}":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"\u{1FB54}":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB55}":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"\u{1FB56}":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"\u{1FB57}":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"\u{1FB58}":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"\u{1FB59}":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"\u{1FB5A}":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"\u{1FB5B}":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"\u{1FB5C}":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB5D}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5E}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5F}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB60}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB61}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"\u{1FB62}":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"\u{1FB63}":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"\u{1FB64}":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"\u{1FB65}":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"\u{1FB66}":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"\u{1FB67}":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"\u{1FB68}":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"\u{1FB69}":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"\u{1FB6A}":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"\u{1FB6B}":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"\u{1FB6C}":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"\u{1FB6D}":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"\u{1FB6E}":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"\u{1FB6F}":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"\u{1FB70}":{type:0,data:[{x:1,y:0,w:1,h:8}]},"\u{1FB71}":{type:0,data:[{x:2,y:0,w:1,h:8}]},"\u{1FB72}":{type:0,data:[{x:3,y:0,w:1,h:8}]},"\u{1FB73}":{type:0,data:[{x:4,y:0,w:1,h:8}]},"\u{1FB74}":{type:0,data:[{x:5,y:0,w:1,h:8}]},"\u{1FB75}":{type:0,data:[{x:6,y:0,w:1,h:8}]},"\u{1FB76}":{type:0,data:[{x:0,y:1,w:8,h:1}]},"\u{1FB77}":{type:0,data:[{x:0,y:2,w:8,h:1}]},"\u{1FB78}":{type:0,data:[{x:0,y:3,w:8,h:1}]},"\u{1FB79}":{type:0,data:[{x:0,y:4,w:8,h:1}]},"\u{1FB7A}":{type:0,data:[{x:0,y:5,w:8,h:1}]},"\u{1FB7B}":{type:0,data:[{x:0,y:6,w:8,h:1}]},"\u{1FB7C}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB7D}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7E}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7F}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB80}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB81}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB82}":{type:0,data:[{x:0,y:0,w:8,h:2}]},"\u{1FB83}":{type:0,data:[{x:0,y:0,w:8,h:3}]},"\u{1FB84}":{type:0,data:[{x:0,y:0,w:8,h:5}]},"\u{1FB85}":{type:0,data:[{x:0,y:0,w:8,h:6}]},"\u{1FB86}":{type:0,data:[{x:0,y:0,w:8,h:7}]},"\u{1FB87}":{type:0,data:[{x:6,y:0,w:2,h:8}]},"\u{1FB88}":{type:0,data:[{x:5,y:0,w:3,h:8}]},"\u{1FB89}":{type:0,data:[{x:3,y:0,w:5,h:8}]},"\u{1FB8A}":{type:0,data:[{x:2,y:0,w:6,h:8}]},"\u{1FB8B}":{type:0,data:[{x:1,y:0,w:7,h:8}]},"\u{1FB8C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"\u{1FB8D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"\u{1FB8E}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"\u{1FB8F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"\u{1FB90}":{type:1,data:[[0,1],[1,0]]},"\u{1FB91}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"\u{1FB92}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"\u{1FB94}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"\u{1FB95}":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"\u{1FB96}":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"\u{1FB97}":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"\u{1FB98}":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"\u{1FB99}":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"\u{1FB9A}":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"\u{1FB9B}":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"\u{1FB9C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"\u{1FB9D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"\u{1FB9E}":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"\u{1FB9F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"\u{1FBA0}":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"\u{1FBA1}":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"\u{1FBA2}":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"\u{1FBA3}":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"\u{1FBA4}":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBA5}":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBA6}":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBA7}":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"\u{1FBA8}":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"\u{1FBA9}":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"\u{1FBAA}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"\u{1FBAB}":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBAC}":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBAD}":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBAE}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"\u{1FBAF}":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"\u{1FBB0}":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"\u{1FBB1}":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"\u{1FBB2}":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"\u{1FBB3}":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"\u{1FBB4}":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"\u{1FBB5}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB6}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB7}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB8}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB9}":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"\u{1FBBA}":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"\u{1FBBB}":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"\u{1FBBC}":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"\u{1FBBD}":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"\u{1FBBE}":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"\u{1FBBF}":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"\u{1FBC0}":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"\u{1FBC1}":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"\u{1FBC2}":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"\u{1FBC3}":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"\u{1FBC4}":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"\u{1FBC5}":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC6}":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC7}":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC8}":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC9}":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBCA}":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"\u{1FBCB}":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"\u{1FBCC}":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"\u{1FBCD}":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"\u{1FBCE}":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"\u{1FBCF}":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"\u{1FBD0}":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"\u{1FBD1}":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"\u{1FBD2}":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"\u{1FBD3}":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"\u{1FBD4}":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"\u{1FBD5}":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"\u{1FBD6}":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"\u{1FBD7}":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"\u{1FBD8}":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"\u{1FBD9}":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDA}":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDB}":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"\u{1FBDC}":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"\u{1FBDD}":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"\u{1FBDE}":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"\u{1FBDF}":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"\u{1FBE0}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"\u{1FBE1}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"\u{1FBE2}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"\u{1FBE3}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"\u{1FBE4}":{type:0,data:[{x:2,y:0,w:4,h:4}]},"\u{1FBE5}":{type:0,data:[{x:2,y:4,w:4,h:4}]},"\u{1FBE6}":{type:0,data:[{x:0,y:2,w:4,h:4}]},"\u{1FBE7}":{type:0,data:[{x:4,y:2,w:4,h:4}]},"\u{1FBE8}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"\u{1FBE9}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"\u{1FBEA}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"\u{1FBEB}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"\u{1FBEC}":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"\u{1FBED}":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"\u{1FBEE}":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"\u{1FBEF}":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"\u{1FBF0}":{type:5,data:{d:o0(126),type:0},scaleType:1},"\u{1FBF1}":{type:5,data:{d:o0(48),type:0},scaleType:1},"\u{1FBF2}":{type:5,data:{d:o0(109),type:0},scaleType:1},"\u{1FBF3}":{type:5,data:{d:o0(121),type:0},scaleType:1},"\u{1FBF4}":{type:5,data:{d:o0(51),type:0},scaleType:1},"\u{1FBF5}":{type:5,data:{d:o0(91),type:0},scaleType:1},"\u{1FBF6}":{type:5,data:{d:o0(95),type:0},scaleType:1},"\u{1FBF7}":{type:5,data:{d:o0(114),type:0},scaleType:1},"\u{1FBF8}":{type:5,data:{d:o0(127),type:0},scaleType:1},"\u{1FBF9}":{type:5,data:{d:o0(123),type:0},scaleType:1},"\u{1FBFA}":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(i,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},$e=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951]);function f(i){return{type:2,data:()=>{let e=[],r=[.375,.25,.375],s=[0,3/8,5/8];for(let a=0;a<3;a++){let o=i>>a*2&1,n=i>>a*2+1&1;o&&n?e.push(`M0,${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L0,${s[a]+r[a]} Z`):o?e.push(`M0,${s[a]} L${.5},${s[a]} L${.5},${s[a]+r[a]} L0,${s[a]+r[a]} Z`):n&&e.push(`M${.5},${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L${.5},${s[a]+r[a]} Z`)}return e.join(" ")}}}function o0(i){let e=[];if(i&64){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.2125} L${Q+.075},${.175} L${m-.075},${.175} L${m},${.2125} L${m-.075},${.25} L${Q+.075},${.25} Z`)}if(i&32){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.175+.075+.015,m=.5-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&16){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.5+.015,m=.825-.075-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&8){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.7875} L${Q+.075},${.75} L${m-.075},${.75} L${m},${.7875} L${m-.075},${.825} L${Q+.075},${.825} Z`)}if(i&4&&e.push(`M${.125},${.515} L${.2},${.515+.0375} L${.2},${.735-.0375} L${.125},${.735} L${.05},${.735-.0375} L${.05},${.515+.0375} Z`),i&2&&e.push(`M${.125},${.265} L${.2},${.265+.0375} L${.2},${.485-.0375} L${.125},${.485} L${.05},${.485-.0375} L${.05},${.265+.0375} Z`),i&1){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.5} L${Q+.075},${.4625} L${m-.075},${.4625} L${m},${.5} L${m-.075},${.5375} L${Q+.075},${.5375} Z`)}return e.join(" ")}var k=0,H=0,T0=!1,_0=!1,$0=!1,z,x0=0,G0=class{constructor(e,t,r,s,a,o){this._terminal=e;this._optionService=t;this._selectionRenderModel=r;this._decorationService=s;this._coreBrowserService=a;this._themeService=o;this.result={fg:0,bg:0,ext:0}}resolve(e,t,r,s,a){this.result.bg=e.bg,this.result.fg=e.fg,this.result.ext=e.bg&268435456?e.extended.ext:0,H=0,k=0,_0=!1,T0=!1,$0=!1,z=this._themeService.colors,x0=0;let o=e.getCode();if(o!==0&&e.extended.underlineStyle===4){let n=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));x0=t*s%(Math.round(n)*2)}if(x0===0&&$e.has(o)&&(x0=t*s%2*2+r*a%2),this._decorationService.forEachDecorationAtCell(t,r,"bottom",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),$0=this._selectionRenderModel.isCellSelected(this._terminal,t,r),$0){if(this.result.fg&67108864||(this.result.bg&50331648)!==0){if(this.result.fg&67108864)switch(this.result.fg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:H=(this.result.fg&16777215)<<8|255;break;case 0:default:H=this._themeService.colors.foreground.rgba}else switch(this.result.bg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:H=(this.result.bg&16777215)<<8|255;break}H=d0.blend(H,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}else H=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(_0=!0,z.selectionForeground&&(k=z.selectionForeground.rgba>>8&16777215,T0=!0),V0(e.getCode())){if(this.result.fg&67108864&&(this.result.bg&50331648)===0)k=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(this.result.fg&67108864)switch(this.result.bg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:k=(this.result.bg&16777215)<<8|255;break}else switch(this.result.fg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:k=(this.result.fg&16777215)<<8|255;break;case 0:default:k=this._themeService.colors.foreground.rgba}k=d0.blend(k,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}T0=!0}}this._decorationService.forEachDecorationAtCell(t,r,"top",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),_0&&($0?H=e.bg&-16777216&-134217729|H|50331648:H=e.bg&-16777216|H|50331648),T0&&(k=e.fg&-16777216&-67108865|k|50331648),this.result.fg&67108864&&(_0&&!T0&&((this.result.bg&50331648)===0?k=this.result.fg&-134217728|z.background.rgba>>8&16777215&16777215|50331648:k=this.result.fg&-134217728|this.result.bg&67108863,T0=!0),!_0&&T0&&((this.result.fg&50331648)===0?H=this.result.bg&-67108864|z.foreground.rgba>>8&16777215&16777215|50331648:H=this.result.bg&-67108864|this.result.fg&67108863,_0=!0)),z=void 0,this.result.bg=_0?H:this.result.bg,this.result.fg=T0?k:this.result.fg,this.result.ext&=536870911,this.result.ext|=x0<<29&3758096384}};var Ke=.5,K0=Be||we?"bottom":"ideographic";function qe(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=Ve[e];if(p){let c=Array.isArray(p)?p:[p];for(let u of c)Pt(i,u,t,r,s,a,o,n,L,T,h,d,_);return!0}return!1}function Pt(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=s,c=a,u=t,C=r;switch(e.scaleType===1&&(p=o,c=n,u=t+(s-o)/2,C=r+(a-n)/2),e.clipPath&&(i.save(),Vt(i,e.clipPath,u,C,p,c)),e.type){case 0:Bt(i,e.data,u,C,p,c);break;case 1:Ut(i,e.data,u,C,p,c,_);break;case 2:Wt(i,e.data,u,C,p,c,T,h,e.strokeWidth);break;case 3:Ht(i,e.data,u,C,p,c,T,e.strokeWidth);break;case 4:Zt(i,e.data,u,C,p,c,T,d);break;case 5:$t(i,e.data,u,C,p,c,L,T,h);break;case 6:wt(i,e.data,u,C,p,c);break}e.clipPath&&i.restore()}function Bt(i,e,t,r,s,a){for(let o=0;o{let F=parseFloat(m);return R%2===0?t+F*s:r+F*a});if(u==="M")i.moveTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="L")i.lineTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="Q")i.quadraticCurveTo(Q[0],Q[1],Q[2],Q[3]),d=Q[0],_=Q[1],T=Q[2],h=Q[3];else if(u==="T"){let m,R;p==="Q"||p==="T"?(m=2*T-d,R=2*h-_):(m=T,R=h),i.quadraticCurveTo(m,R,Q[0],Q[1]),d=m,_=R,T=Q[0],h=Q[1]}else u==="C"&&(i.bezierCurveTo(Q[0],Q[1],Q[2],Q[3],Q[4],Q[5]),d=Q[2],_=Q[3],T=Q[4],h=Q[5]);p=u}n!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*n,i.stroke()):i.fill()}function kt(i,e,t,r,s,a,o,n,L,T){if(r===0||s===0){i.lineTo(L,T);return}r=Math.abs(r),s=Math.abs(s);let h=Math.cos(a),d=Math.sin(a),_=(e-L)/2,p=(t-T)/2,c=h*_+d*p,u=-d*_+h*p,C=r*r,Q=s*s,m=c*c,R=u*u,F=m/C+R/Q;if(F>1){let I=Math.sqrt(F);r*=I,s*=I,C=r*r,Q=s*s}let E=(C*Q-C*R-Q*m)/(C*R+Q*m);E<0&&(E=0);let y=(o===n?-1:1)*Math.sqrt(E),Y=y*(r*u/s),X=y*-(s*c/r),A0=h*Y-d*X+(e+L)/2,t0=d*Y+h*X+(t+T)/2,L0=(c-Y)/r,A=(u-X)/s,n0=(-c-Y)/r,a0=(-u-X)/s,$=Math.atan2(A,L0),q=Math.atan2(a0,n0)-$;n===0&&q>0?q-=2*Math.PI:n===1&&q<0&&(q+=2*Math.PI);let g0=$+q;i.ellipse(A0,t0,r,s,a,$,g0,n===0)}function Zt(i,e,t,r,s,a,o,n){i.save(),i.fillRect(t,r,s,a),n&&(i.fillStyle=n,i.strokeStyle=n),i.lineWidth=o,i.lineCap="square",i.beginPath();for(let L of e.d.split(" ")){let T=L[0],h=L.substring(1).split(",");if(!h[0]||!h[1]){T==="Z"&&i.closePath();continue}let d=h.map((_,p)=>{let c=parseFloat(_);return p%2===0?t+c*s:r+c*a});T==="M"?i.moveTo(d[0],d[1]):T==="L"&&i.lineTo(d[0],d[1])}e.type===1?i.stroke():i.fill(),i.restore()}var Xe=new Map;function Ut(i,e,t,r,s,a,o=0){let n=Xe.get(e);n||(n=new Map,Xe.set(e,n));let L=i.fillStyle;if(typeof L!="string")throw new Error(`Unexpected fillStyle type "${L}"`);let T=n.get(L);if(!T){let _=e[0].length,p=e.length,c=i.canvas.ownerDocument.createElement("canvas");c.width=_,c.height=p;let u=g(c.getContext("2d")),C=new ImageData(_,p),Q,m,R,F;if(L.startsWith("#"))Q=parseInt(L.slice(1,3),16),m=parseInt(L.slice(3,5),16),R=parseInt(L.slice(5,7),16),F=L.length>7&&parseInt(L.slice(7,9),16)||1;else if(L.startsWith("rgba"))[Q,m,R,F]=L.substring(5,L.length-1).split(",").map(E=>parseFloat(E));else throw new Error(`Unexpected fillStyle color format "${L}" when drawing pattern glyph`);for(let E=0;E>1&1,d=o&1;h!==0||d!==0?T.setTransform(new DOMMatrix().translateSelf(-h,-d)):T.setTransform(new DOMMatrix),i.fillStyle=T,i.fillRect(t,r,s,a)}function Wt(i,e,t,r,s,a,o,n,L){i.save(),i.beginPath(),i.rect(t,r,s,a),i.clip(),i.beginPath();let T;if(typeof e=="function"){let _=.15/a*s;T=e(.15,_)}else T=e;let h={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let d of T.split(" ")){let _=d[0];if(_==="Z"){i.closePath(),h.lastCommand=_;continue}let p=je[_];if(!p){n.error(`Could not find drawing instructions for "${_}"`);continue}let c=d.substring(1).split(",");!c[0]||!c[1]||(p(i,Je(c,s,a,t,r,!0,o,0,0,!1),h),h.lastCommand=_)}L!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*L,i.stroke()):i.fill(),i.closePath(),i.restore()}function Vt(i,e,t,r,s,a){i.beginPath();for(let o of e.split(" ")){let n=o[0];if(n==="Z"){i.closePath();continue}let L=o.substring(1).split(",");if(!L[0]||!L[1])continue;let T=t+parseFloat(L[0])*s,h=r+parseFloat(L[1])*a;n==="M"?i.moveTo(T,h):n==="L"&&i.lineTo(T,h)}i.clip()}function $t(i,e,t,r,s,a,o,n,L){let T=new Path2D;T.rect(t,r,s,a),i.clip(T),i.beginPath();let h=o/12;i.lineWidth=n*h;let d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let _ of e.d.split(" ")){let p=_[0];if(p==="Z"){i.closePath(),d.lastCommand=p;continue}let c=je[p];if(!c){L.error(`Could not find drawing instructions for "${p}"`);continue}let u=_.substring(1).split(",");!u[0]||!u[1]||(c(i,Je(u,s,a,t,r,!1,n,(e.leftPadding??0)*(h/2),(e.rightPadding??0)*(h/2)),d),d.lastCommand=p)}e.type===1?(i.strokeStyle=i.fillStyle,i.stroke()):i.fill(),i.closePath()}function Ye(i,e,t=0){return Math.max(Math.min(i,e),t)}var je={C:(i,e,t)=>{i.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),t.lastControlX=e[2],t.lastControlY=e[3],t.currentX=e[4],t.currentY=e[5]},L:(i,e,t)=>{i.lineTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},M:(i,e,t)=>{i.moveTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},Q:(i,e,t)=>{i.quadraticCurveTo(e[0],e[1],e[2],e[3]),t.lastControlX=e[0],t.lastControlY=e[1],t.currentX=e[2],t.currentY=e[3]},T:(i,e,t)=>{let r,s;t.lastCommand==="Q"||t.lastCommand==="T"?(r=2*t.currentX-t.lastControlX,s=2*t.currentY-t.lastControlY):(r=t.currentX,s=t.currentY),i.quadraticCurveTo(r,s,e[0],e[1]),t.lastControlX=r,t.lastControlY=s,t.currentX=e[0],t.currentY=e[1]}};function Je(i,e,t,r,s,a,o,n=0,L=0,T=!0){let h=i.map(d=>parseFloat(d)||parseInt(d));if(h.length<2)throw new Error("Too few arguments for instruction");for(let d=0;da){s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=a}this.clear()}},Ce=class extends X0{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Qe=class extends X0{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},et="requestIdleCallback"in globalThis?Qe:Ce;var s0=class i{constructor(){this.fg=0;this.bg=0;this.extended=new P0}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},P0=class i{constructor(e=0,t=0){this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new i(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var U=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(e,t,r)=>{if(this._disposed)return M(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let a=M(()=>{let o=this._listeners.indexOf(s);o!==-1&&this._listeners.splice(o,1)});return r&&(Array.isArray(r)?r.push(a):r.add(a)),a},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:r}=this._listeners[0];t.call(r,e);return}default:{let t=this._listeners.slice();for(let{fn:r,thisArgs:s}of t)r.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},c0;(s=>{function i(a,o){return a(n=>o.fire(n))}s.forward=i;function e(a,o){return(n,L,T)=>a(h=>n.call(L,o(h)),void 0,T)}s.map=e;function t(...a){return(o,n,L)=>{let T=new R0;for(let h of a)T.add(h(d=>o.call(n,d)));return L&&(Array.isArray(L)?L.push(T):L.add(T)),T}}s.any=t;function r(a,o,n){return o(n),a(L=>o(L))}s.runAndSubscribe=r})(c0||={});var tt={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},B0=2;var w0,J=class i{constructor(e,t,r,s){this._document=e;this._config=t;this._unicodeService=r;this._logService=s;this._didWarmUp=!1;this._cacheMap=new D0;this._cacheMapCombined=new D0;this._pages=[];this._activePages=[];this._workBoundingBox={top:0,left:0,bottom:0,right:0};this._workAttributeData=new s0;this._textureSize=512;this._onAddTextureAtlasCanvas=new U;this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=new U;this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._requestClearModel=!1;this._createNewPage(),this._tmpCanvas=it(e,this._config.deviceCellWidth*4+B0*2,this._config.deviceCellHeight+B0*2),this._tmpCtx=g(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){let e=new et(this._logService);for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let r=this._drawToCache(t,0,0,0,!1,void 0);this._cacheMap.set(t,0,0,0,r)}})}beginFrame(){let e=this._requestClearModel;return this._requestClearModel=!1,e}clearTexture(){if(!(this._pages[0].currentRow.x===0&&this._pages[0].currentRow.y===0)){for(let e of this._pages)e.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(i.maxAtlasPages&&this._pages.length>=Math.max(4,i.maxAtlasPages)){let t=this._pages.filter(T=>T.canvas.width*2<=(i.maxTextureSize||4096)).sort((T,h)=>h.canvas.width!==T.canvas.width?h.canvas.width-T.canvas.width:h.percentageUsed-T.percentageUsed),r=-1,s=0;for(let T=0;TT.canvas.width!==a[0].canvas.width)){let T=new u0(this._document,this._textureSize);return this._pages.push(T),this._activePages.push(T),this._onAddTextureAtlasCanvas.fire(T.canvas),T}let o=a.map(T=>T.glyphs[0].texturePage).sort((T,h)=>T-h),n=this.pages.length-a.length,L=this._mergePages(a,n);L.version=++u0.nextVersion;for(let T=o.length-1;T>=0;T--)this._deletePage(o[T]);this.pages.push(L),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(L.canvas)}let e=new u0(this._document,this._textureSize);return this._pages.push(e),this._activePages.push(e),this._onAddTextureAtlasCanvas.fire(e.canvas),e}_mergePages(e,t){let r=e[0].canvas.width*2,s=new u0(this._document,r,e);for(let[a,o]of e.entries()){let n=a*o.canvas.width%r,L=Math.floor(a/2)*o.canvas.height;s.ctx.drawImage(o.canvas,n,L);for(let h of o.glyphs)h.texturePage=t,h.sizeClipSpace.x=h.size.x/r,h.sizeClipSpace.y=h.size.y/r,h.texturePosition.x+=n,h.texturePosition.y+=L,h.texturePositionClipSpace.x=h.texturePosition.x/r,h.texturePositionClipSpace.y=h.texturePosition.y/r;this._onRemoveTextureAtlasCanvas.fire(o.canvas);let T=this._activePages.indexOf(o);T!==-1&&this._activePages.splice(T,1)}return s}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw new Error("No color found for idx "+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,r,s){if(this._config.allowTransparency)return K;let a;switch(e){case 16777216:case 33554432:a=this._getColorFromAnsiIndex(t);break;case 50331648:let o=s0.toColorRGB(t);a=Z.toColor(o[0],o[1],o[2]);break;case 0:default:r?a=I0.opaque(this._config.colors.foreground):a=this._config.colors.background;break}return this._config.allowTransparency||(a=I0.opaque(a)),a}_getForegroundColor(e,t,r,s,a,o,n,L,T,h){let d=this._getMinimumContrastColor(e,t,r,s,a,o,n,T,L,h);if(d)return d;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&T&&o<8&&(o+=8),_=this._getColorFromAnsiIndex(o);break;case 50331648:let p=s0.toColorRGB(o);_=Z.toColor(p[0],p[1],p[2]);break;case 0:default:n?_=this._config.colors.background:_=this._config.colors.foreground}return this._config.allowTransparency&&(_=I0.opaque(_)),L&&(_=I0.multiplyOpacity(_,Ke)),_}_resolveBackgroundRgba(e,t,r){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,r,s){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,r,s,a,o,n,L,T,h){if(this._config.minimumContrastRatio===1||h)return;let d=this._getContrastCache(T),_=d.getColor(e,s);if(_!==void 0)return _??void 0;let p=this._resolveBackgroundRgba(t,r,n),c=this._resolveForegroundRgba(a,o,n,L),u=d0.ensureContrastRatio(p,c,this._config.minimumContrastRatio/(T?2:1));if(!u){d.setColor(e,s,null);return}let C=Z.toColor(u>>24&255,u>>16&255,u>>8&255);return d.setColor(e,s,C),C}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(e,t,r,s,a,o){let n=typeof e=="number"?String.fromCharCode(e):e;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display="none",o.append(this._tmpCanvas));let L=Math.min(this._config.deviceCellWidth*Math.max(n.length,2)+B0*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width=b?b*2-f0:b-f0;!(f0>=b)===!1||Z0===0?(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j+Z0,G),this._tmpCtx.lineTo(Q0,G)):(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(j+Z0,G),this._tmpCtx.moveTo(j+Z0+b,G),this._tmpCtx.lineTo(Q0,G)),f0=We(Q0-j,b,f0);break;case 5:let vt=.6,Ot=.3,de=Q0-j,Se=Math.floor(vt*de),Re=Math.floor(Ot*de),It=de-Se-Re;this._tmpCtx.setLineDash([Se,Re,It]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break;case 1:default:this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break}y0||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!n0&&this._config.fontSize>=12&&!this._config.allowTransparency&&n!==" "){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";let r0=this._tmpCtx.measureText(n);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in r0&&r0.actualBoundingBoxDescent>0){this._tmpCtx.save();let y0=new Path2D;y0.rect(i0,C0-Math.ceil(b/2),this._config.deviceCellWidth*$,v0-C0+Math.ceil(b/2)),this._tmpCtx.clip(y0),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=y.css,this._tmpCtx.strokeText(n,A,A+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(Q){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+D),this._tmpCtx.stroke()}if(n0||this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight),n==="_"&&!this._config.allowTransparency){let b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0);if(b)for(let D=1;D<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=y.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight-D),b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0),!!b);D++);}if(C){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),D=this._tmpCtx.lineWidth%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.stroke()}this._tmpCtx.restore();let q=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),g0;if(this._config.allowTransparency?g0=Gt(q):g0=fe(q,y,L0,a0),g0)return tt;let I=this._findGlyphBoundingBox(q,this._workBoundingBox,L,t0,n0,A),O,v;for(;;){if(this._activePages.length===0){let b=this._createNewPage();O=b,v=b.currentRow,v.height=I.size.y;break}O=this._activePages[this._activePages.length-1],v=O.currentRow;for(let b of this._activePages)I.size.y<=b.currentRow.height&&(O=b,v=b.currentRow);for(let b=this._activePages.length-1;b>=0;b--)for(let D of this._activePages[b].fixedRows)D.height<=v.height&&I.size.y<=D.height&&(O=this._activePages[b],v=D);if(I.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new u0(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),O=this._overflowSizePage,v=this._overflowSizePage.currentRow,v.x+I.size.x>=O.canvas.width&&(v.x=0,v.y+=v.height,v.height=0);break}if(v.y+I.size.y>=O.canvas.height||v.height>I.size.y+2){let b=!1;if(O.currentRow.y+O.currentRow.height+I.size.y>=O.canvas.height){let D;for(let i0 of this._activePages)if(i0.currentRow.y+i0.currentRow.height+I.size.y=i.maxAtlasPages&&v.y+I.size.y<=O.canvas.height&&v.height>=I.size.y&&v.x+I.size.x<=O.canvas.width)b=!0;else{let i0=this._createNewPage();O=i0,v=i0.currentRow,v.height=I.size.y,b=!0}}b||(O.currentRow.height>0&&O.fixedRows.push(O.currentRow),v={x:0,y:O.currentRow.y+O.currentRow.height,height:I.size.y},O.fixedRows.push(v),O.currentRow={x:0,y:v.y+v.height,height:0})}if(v.x+I.size.x<=O.canvas.width)break;v===O.currentRow?(v.x=0,v.y+=v.height,v.height=0):O.fixedRows.splice(O.fixedRows.indexOf(v),1)}return I.texturePage=this._pages.indexOf(O),I.texturePosition.x=v.x,I.texturePosition.y=v.y,I.texturePositionClipSpace.x=v.x/O.canvas.width,I.texturePositionClipSpace.y=v.y/O.canvas.height,I.sizeClipSpace.x/=O.canvas.width,I.sizeClipSpace.y/=O.canvas.height,v.height=Math.max(v.height,I.size.y),v.x+=I.size.x,O.ctx.putImageData(q,I.texturePosition.x-this._workBoundingBox.left,I.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,I.size.x,I.size.y),O.addGlyph(I),O.version=++u0.nextVersion,I}_findGlyphBoundingBox(e,t,r,s,a,o){t.top=0;let n=s?this._config.deviceCellHeight:this._tmpCanvas.height,L=s?this._config.deviceCellWidth:r,T=!1;for(let h=0;h=o;h--){for(let d=0;d=0;h--){for(let d=0;d>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=t.rgba>>>24,L=t.rgba>>>16&255,T=t.rgba>>>8&255,h=Math.floor((Math.abs(s-n)+Math.abs(a-L)+Math.abs(o-T))/12),d=!0;for(let _=0;_0)return!1;return!0}function it(i,e,t){let r=i.createElement("canvas");return r.width=e,r.height=t,r}function rt(i,e,t,r,s,a,o,n,L=!0){let T={foreground:a.foreground,background:a.background,cursor:K,cursorAccent:K,selectionForeground:K,selectionBackgroundTransparent:K,selectionBackgroundOpaque:K,selectionInactiveBackgroundTransparent:K,selectionInactiveBackgroundOpaque:K,overviewRulerBorder:K,scrollbarSliderBackground:K,scrollbarSliderHoverBackground:K,scrollbarSliderActiveBackground:K,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:L,devicePixelRatio:o,deviceMaxTextureSize:n,letterSpacing:s.letterSpacing,lineHeight:s.lineHeight,deviceCellWidth:i,deviceCellHeight:e,deviceCharWidth:t,deviceCharHeight:r,fontFamily:s.fontFamily,fontSize:s.fontSize,fontWeight:s.fontWeight,fontWeightBold:s.fontWeightBold,allowTransparency:s.allowTransparency,drawBoldTextInBrightColors:s.drawBoldTextInBrightColors,minimumContrastRatio:s.minimumContrastRatio,colors:T}}function Ee(i,e){for(let t=0;t=0){if(Ee(u.config,h))return u.atlas;u.ownedBy.length===1?(u.atlas.dispose(),e0.splice(c,1)):u.ownedBy.splice(C,1);break}}for(let c=0;c{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(e=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0){this._restartInterval(t);return}}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(t);return}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},e)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}};function ve(i,e,t){let r=new e.ResizeObserver(s=>{let a=s.find(L=>L.target===i);if(!a)return;if(!("devicePixelContentBoxSize"in a)){r?.disconnect(),r=void 0;return}let o=a.devicePixelContentBoxSize[0].inlineSize,n=a.devicePixelContentBoxSize[0].blockSize;o>0&&n>0&&t(o,n)});try{r.observe(i,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return M(()=>r?.disconnect())}function nt(i){return i>65535?(i-=65536,String.fromCharCode((i>>10)+55296)+String.fromCharCode(i%1024+56320)):String.fromCharCode(i)}var j0=class i extends s0{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new P0;this.combinedData=""}static fromCharData(t){let r=new i;return r.setFromCharData(t),r}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?nt(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){this.fg=t[0],this.bg=0;let r=!1;if(t[1].length>2)r=!0;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let a=t[1].charCodeAt(1);56320<=a&&a<=57343?this.content=(s-55296)*1024+a-56320+65536|t[2]<<22:r=!0}else r=!0}else this.content=t[1].charCodeAt(0)|t[2]<<22;r&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;let r=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(r&&s)&&(r!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return!1}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}};var J0=class extends W{constructor(t,r,s){super();this._renderCallback=t;this._coreBrowserService=r;this._optionsService=s;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",a=>{this.setIntervalDuration(a)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(M(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let r=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),r||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var te=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function ie(i,e,t,r){let s=g(i.createProgram());if(i.attachShader(s,g(at(i,i.VERTEX_SHADER,e,r))),i.attachShader(s,g(at(i,i.FRAGMENT_SHADER,t,r))),i.linkProgram(s),i.getProgramParameter(s,i.LINK_STATUS))return s;r.error(i.getProgramInfoLog(s)),i.deleteProgram(s)}function at(i,e,t,r){let s=g(i.createShader(e));if(i.shaderSource(s,t),i.compileShader(s),i.getShaderParameter(s,i.COMPILE_STATUS))return s;r.error(i.getShaderInfoLog(s)),i.deleteShader(s)}function ot(i,e){let t=Math.min(i.length*2,e),r=new Float32Array(t);for(let s=0;sFt(i))}var R0=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(let e of this._disposables)e.dispose();this._disposables.clear()}},W=class{constructor(){this._store=new R0}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}};W.None=Object.freeze({dispose(){}});var m0=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var Pe=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),O0=Pe?"node":navigator.userAgent,ce=Pe?"node":navigator.platform,Be=O0.includes("Firefox"),n1=O0.includes("Chrome"),we=O0.includes("Edge"),ue=/^((?!chrome|android).)*safari/i.test(O0);function He(){if(!ue)return 0;let i=O0.match(/Version\/(\d+)/);return i===null||i.length<2?0:parseInt(i[1],10)}var a1=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(ce),o1=["Windows","Win16","Win32","WinCE"].includes(ce),T1=ce.indexOf("Linux")>=0,l1=/\bCrOS\b/.test(O0);var P=0,B=0,w=0,x=0,K={css:"#00000000",rgba:0},Z;(r=>{function i(s,a,o,n){return n!==void 0?`#${E0(s)}${E0(a)}${E0(o)}${E0(n)}`:`#${E0(s)}${E0(a)}${E0(o)}`}r.toCss=i;function e(s,a,o,n=255){return(s<<24|a<<16|o<<8|n)>>>0}r.toRgba=e;function t(s,a,o,n){return{css:r.toCss(s,a,o,n),rgba:r.toRgba(s,a,o,n)}}r.toColor=t})(Z||={});var I0;(n=>{function i(L,T){if(x=(T.rgba&255)/255,x===1)return{css:T.css,rgba:T.rgba};let h=T.rgba>>24&255,d=T.rgba>>16&255,_=T.rgba>>8&255,p=L.rgba>>24&255,c=L.rgba>>16&255,u=L.rgba>>8&255;P=p+Math.round((h-p)*x),B=c+Math.round((d-c)*x),w=u+Math.round((_-u)*x);let C=Z.toCss(P,B,w),Q=Z.toRgba(P,B,w);return{css:C,rgba:Q}}n.blend=i;function e(L){return(L.rgba&255)===255}n.isOpaque=e;function t(L,T,h){let d=d0.ensureContrastRatio(L.rgba,T.rgba,h);if(d)return Z.toColor(d>>24&255,d>>16&255,d>>8&255)}n.ensureContrastRatio=t;function r(L){let T=(L.rgba|255)>>>0;return[P,B,w]=d0.toChannels(T),{css:Z.toCss(P,B,w),rgba:T}}n.opaque=r;function s(L,T){return x=Math.round(T*255),[P,B,w]=d0.toChannels(L.rgba),{css:Z.toCss(P,B,w,x),rgba:Z.toRgba(P,B,w,x)}}n.opacity=s;function a(L,T){return x=L.rgba&255,s(L,x*T/255)}n.multiplyOpacity=a;function o(L){return[L.rgba>>24&255,L.rgba>>16&255,L.rgba>>8&255]}n.toColorRGB=o})(I0||={});var Mt;(r=>{let i,e;try{let s=document.createElement("canvas");s.width=1,s.height=1;let a=s.getContext("2d",{willReadFrequently:!0});a&&(i=a,i.globalCompositeOperation="copy",e=i.createLinearGradient(0,0,1,1))}catch{}function t(s){if(s.match(/#[\da-f]{3,8}/i))switch(s.length){case 4:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),Z.toColor(P,B,w);case 5:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),x=parseInt(s.slice(4,5).repeat(2),16),Z.toColor(P,B,w,x);case 7:return{css:s,rgba:(parseInt(s.slice(1),16)<<8|255)>>>0};case 9:return{css:s,rgba:parseInt(s.slice(1),16)>>>0}}let a=s.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return P=parseInt(a[1],10),B=parseInt(a[2],10),w=parseInt(a[3],10),x=Math.round((a[5]===void 0?1:parseFloat(a[5]))*255),Z.toColor(P,B,w,x);if(s==="transparent")return{css:"transparent",rgba:0};if(!i||!e)throw new Error("css.toColor: Unsupported css format");if(i.fillStyle=e,i.fillStyle=s,typeof i.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(i.fillRect(0,0,1,1),[P,B,w,x]=i.getImageData(0,0,1,1).data,x!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:Z.toRgba(P,B,w,x),css:s}}r.toColor=t})(Mt||={});var V;(t=>{function i(r){return e(r>>16&255,r>>8&255,r&255)}t.relativeLuminance=i;function e(r,s,a){let o=r/255,n=s/255,L=a/255,T=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),h=n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4),d=L<=.03928?L/12.92:Math.pow((L+.055)/1.055,2.4);return T*.2126+h*.7152+d*.0722}t.relativeLuminance2=e})(V||={});var d0;(a=>{function i(o,n){if(x=(n&255)/255,x===1)return n;let L=n>>24&255,T=n>>16&255,h=n>>8&255,d=o>>24&255,_=o>>16&255,p=o>>8&255;return P=d+Math.round((L-d)*x),B=_+Math.round((T-_)*x),w=p+Math.round((h-p)*x),Z.toRgba(P,B,w)}a.blend=i;function e(o,n,L){let T=V.relativeLuminance(o>>8),h=V.relativeLuminance(n>>8);if(h0(T,h)>8));if(u>8));return u>Q?c:C}return c}let _=r(o,n,L),p=h0(T,V.relativeLuminance(_>>8));if(p>8));return p>u?_:c}return _}}a.ensureContrastRatio=e;function t(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u0||p>0||c>0);)_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),c-=Math.max(0,Math.ceil(c*.1)),u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));return(_<<24|p<<16|c<<8|255)>>>0}a.reduceLuminance=t;function r(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u>>0}a.increaseLuminance=r;function s(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}a.toChannels=s})(d0||={});function E0(i){let e=i.toString(16);return e.length<2?"0"+e:e}function h0(i,e){return i=128512&&i<=128591||i>=127744&&i<=128511||i>=128640&&i<=128767||i>=9728&&i<=9983||i>=9984&&i<=10175||i>=65024&&i<=65039||i>=129280&&i<=129535||i>=127462&&i<=127487}function Ze(i,e,t,r){return e===1&&t>Math.ceil(r*1.5)&&i!==void 0&&i>255&&!yt(i)&&!W0(i)&&!At(i)}function V0(i){return W0(i)||gt(i)}function Ue(){return{css:{canvas:U0(),cell:U0()},device:{canvas:U0(),cell:U0(),char:{width:0,height:0,left:0,top:0}}}}function U0(){return{width:0,height:0}}function We(i,e,t=0){return(i-(Math.round(e)*2-t))%(Math.round(e)*2)}var l;(C=>(C.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),C.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),C.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),C.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),C.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),C.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),C.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.CURVE_UP_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_UP_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),C.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),C.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),C.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),C.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),C.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})))(l||={});var Ve={"\u2500":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"\u2501":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"\u2502":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"\u2503":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"\u2504":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"\u2505":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"\u2506":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"\u2507":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"\u2508":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"\u2509":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"\u250A":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"\u250B":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"\u250C":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"\u250D":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u250E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u250F":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"\u2510":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"\u2511":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2512":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2513":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"\u2514":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"\u2515":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2516":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2517":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"\u2518":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"\u2519":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u251A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251B":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"\u251C":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"\u251D":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u251E":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251F":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2520":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2521":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2522":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2523":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"\u2524":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"\u2525":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2526":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2527":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2528":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2529":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u252A":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u252B":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"\u252C":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"\u252D":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u252E":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u252F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2530":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2531":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2532":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2533":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"\u2534":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"\u2535":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2536":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2537":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2538":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2539":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u253A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u253B":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"\u253C":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"\u253D":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u253E":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u253F":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2540":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2541":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2542":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2543":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u2544":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2545":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2546":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2547":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"\u2548":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"\u2549":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"\u254A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"\u254B":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"\u254C":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"\u254D":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"\u254E":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"\u254F":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"\u2550":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2551":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2552":{type:2,data:(i,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2553":{type:2,data:(i,e)=>`M${.5-i},1 L${.5-i},.5 L1,.5 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2554":{type:2,data:(i,e)=>`M1,${.5-e} L${.5-i},${.5-e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2555":{type:2,data:(i,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2556":{type:2,data:(i,e)=>`M${.5+i},1 L${.5+i},.5 L0,.5 M${.5-i},.5 L${.5-i},1`,strokeWidth:1},"\u2557":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5+i},${.5-e} L${.5+i},1`,strokeWidth:1},"\u2558":{type:2,data:(i,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"\u2559":{type:2,data:(i,e)=>`M1,.5 L${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u255A":{type:2,data:(i,e)=>`M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0 M1,${.5+e} L${.5-i},${.5+e} L${.5-i},0`,strokeWidth:1},"\u255B":{type:2,data:(i,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"\u255C":{type:2,data:(i,e)=>`M0,.5 L${.5+i},.5 L${.5+i},0 M${.5-i},.5 L${.5-i},0`,strokeWidth:1},"\u255D":{type:2,data:(i,e)=>`M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M0,${.5+e} L${.5+i},${.5+e} L${.5+i},0`,strokeWidth:1},"\u255E":{type:2,data:(i,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u255F":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1 M${.5+i},.5 L1,.5`,strokeWidth:1},"\u2560":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u2561":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2562":{type:2,data:(i,e)=>`M0,.5 L${.5-i},.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2563":{type:2,data:(i,e)=>`M${.5+i},0 L${.5+i},1 M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0`,strokeWidth:1},"\u2564":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"\u2565":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},1 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2566":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2567":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2568":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u2569":{type:2,data:(i,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256A":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u256B":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u256C":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256D":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u256E":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u256F":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u2570":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u2571":{type:2,data:"M1,0 L0,1",strokeWidth:1},"\u2572":{type:2,data:"M0,0 L1,1",strokeWidth:1},"\u2573":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"\u2574":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"\u2575":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"\u2576":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"\u2577":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"\u2578":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"\u2579":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"\u257A":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"\u257B":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"\u257C":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u257D":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u257E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u257F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2580":{type:0,data:[{x:0,y:0,w:8,h:4}]},"\u2581":{type:0,data:[{x:0,y:7,w:8,h:1}]},"\u2582":{type:0,data:[{x:0,y:6,w:8,h:2}]},"\u2583":{type:0,data:[{x:0,y:5,w:8,h:3}]},"\u2584":{type:0,data:[{x:0,y:4,w:8,h:4}]},"\u2585":{type:0,data:[{x:0,y:3,w:8,h:5}]},"\u2586":{type:0,data:[{x:0,y:2,w:8,h:6}]},"\u2587":{type:0,data:[{x:0,y:1,w:8,h:7}]},"\u2588":{type:0,data:[{x:0,y:0,w:8,h:8}]},"\u2589":{type:0,data:[{x:0,y:0,w:7,h:8}]},"\u258A":{type:0,data:[{x:0,y:0,w:6,h:8}]},"\u258B":{type:0,data:[{x:0,y:0,w:5,h:8}]},"\u258C":{type:0,data:[{x:0,y:0,w:4,h:8}]},"\u258D":{type:0,data:[{x:0,y:0,w:3,h:8}]},"\u258E":{type:0,data:[{x:0,y:0,w:2,h:8}]},"\u258F":{type:0,data:[{x:0,y:0,w:1,h:8}]},"\u2590":{type:0,data:[{x:4,y:0,w:4,h:8}]},"\u2591":{type:1,data:[[1,0],[0,0]]},"\u2592":{type:1,data:[[1,0],[0,1]]},"\u2593":{type:1,data:[[1,1],[1,0]]},"\u2594":{type:0,data:[{x:0,y:0,w:8,h:1}]},"\u2595":{type:0,data:[{x:7,y:0,w:1,h:8}]},"\u2596":{type:0,data:[{x:0,y:4,w:4,h:4}]},"\u2597":{type:0,data:[{x:4,y:4,w:4,h:4}]},"\u2598":{type:0,data:[{x:0,y:0,w:4,h:4}]},"\u2599":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\u259A":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"\u259B":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"\u259C":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"\u259D":{type:0,data:[{x:4,y:0,w:4,h:4}]},"\u259E":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"\u259F":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\uE0A0":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"\uE0A1":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0A2":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"\uE0A3":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0B0":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"\uE0B1":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B2":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"\uE0B3":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B4":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"\uE0B5":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"\uE0B6":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"\uE0B7":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"\uE0B8":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"\uE0B9":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BA":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"\uE0BB":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BC":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"\uE0BD":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BE":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"\uE0BF":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0C0":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"\uE0C1":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"\uE0C2":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"\uE0C3":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"\uE0C4":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"\uE0C5":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"\uE0C6":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"\uE0C7":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"\uE0C8":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"\uE0CA":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"\uE0CC":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"\uE0CD":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"\uE0CE":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"\uE0CF":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"\uE0D0":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"\uE0D1":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"\uE0D2":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"\uE0D4":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"\uEE00":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"\uEE01":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"\uEE02":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"\uEE03":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"\uEE04":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"\uEE05":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"\uEE06":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"\uEE07":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"\uEE08":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE09":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0A":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0B":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"\uF5D0":l.LINE_H,"\uF5D1":l.LINE_V,"\uF5D2":l.FADE_RIGHT,"\uF5D3":l.FADE_LEFT,"\uF5D4":l.FADE_DOWN,"\uF5D5":l.FADE_UP,"\uF5D6":l.CURVE_DOWN_RIGHT,"\uF5D7":l.CURVE_DOWN_LEFT,"\uF5D8":l.CURVE_UP_RIGHT,"\uF5D9":l.CURVE_UP_LEFT,"\uF5DA":[l.LINE_V,l.CURVE_UP_RIGHT],"\uF5DB":[l.LINE_V,l.CURVE_DOWN_RIGHT],"\uF5DC":[l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5DD":[l.LINE_V,l.CURVE_UP_LEFT],"\uF5DE":[l.LINE_V,l.CURVE_DOWN_LEFT],"\uF5DF":[l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E0":[l.LINE_H,l.CURVE_DOWN_LEFT],"\uF5E1":[l.LINE_H,l.CURVE_DOWN_RIGHT],"\uF5E2":[l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E3":[l.LINE_H,l.CURVE_UP_LEFT],"\uF5E4":[l.LINE_H,l.CURVE_UP_RIGHT],"\uF5E5":[l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E6":[l.LINE_V,l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E7":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E8":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E9":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5EA":[l.LINE_V,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5EB":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EC":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5ED":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EE":[l.NODE_FILL,l.NODE_STROKE],"\uF5EF":l.NODE_STROKE,"\uF5F0":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F1":[l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F2":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F3":[l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F4":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F5":[l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F6":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F7":[l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F8":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP],"\uF5F9":[l.NODE_STROKE,l.NODE_LINE_UP],"\uF5FA":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FB":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FC":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FD":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FE":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF5FF":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF600":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF601":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF602":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF603":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF604":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF605":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF606":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF607":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF608":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF609":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60A":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60B":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60C":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60D":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\u{1FB00}":f(1),"\u{1FB01}":f(2),"\u{1FB02}":f(3),"\u{1FB03}":f(4),"\u{1FB04}":f(5),"\u{1FB05}":f(6),"\u{1FB06}":f(7),"\u{1FB07}":f(8),"\u{1FB08}":f(9),"\u{1FB09}":f(10),"\u{1FB0A}":f(11),"\u{1FB0B}":f(12),"\u{1FB0C}":f(13),"\u{1FB0D}":f(14),"\u{1FB0E}":f(15),"\u{1FB0F}":f(16),"\u{1FB10}":f(17),"\u{1FB11}":f(18),"\u{1FB12}":f(19),"\u{1FB13}":f(20),"\u{1FB14}":f(22),"\u{1FB15}":f(23),"\u{1FB16}":f(24),"\u{1FB17}":f(25),"\u{1FB18}":f(26),"\u{1FB19}":f(27),"\u{1FB1A}":f(28),"\u{1FB1B}":f(29),"\u{1FB1C}":f(30),"\u{1FB1D}":f(31),"\u{1FB1E}":f(32),"\u{1FB1F}":f(33),"\u{1FB20}":f(34),"\u{1FB21}":f(35),"\u{1FB22}":f(36),"\u{1FB23}":f(37),"\u{1FB24}":f(38),"\u{1FB25}":f(39),"\u{1FB26}":f(40),"\u{1FB27}":f(41),"\u{1FB28}":f(43),"\u{1FB29}":f(44),"\u{1FB2A}":f(45),"\u{1FB2B}":f(46),"\u{1FB2C}":f(47),"\u{1FB2D}":f(48),"\u{1FB2E}":f(49),"\u{1FB2F}":f(50),"\u{1FB30}":f(51),"\u{1FB31}":f(52),"\u{1FB32}":f(53),"\u{1FB33}":f(54),"\u{1FB34}":f(55),"\u{1FB35}":f(56),"\u{1FB36}":f(57),"\u{1FB37}":f(58),"\u{1FB38}":f(59),"\u{1FB39}":f(60),"\u{1FB3A}":f(61),"\u{1FB3B}":f(62),"\u{1FB3C}":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"\u{1FB3D}":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"\u{1FB3E}":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"\u{1FB3F}":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"\u{1FB40}":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"\u{1FB41}":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB42}":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"\u{1FB43}":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB44}":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"\u{1FB45}":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"\u{1FB46}":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"\u{1FB47}":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"\u{1FB48}":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"\u{1FB49}":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"\u{1FB4A}":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"\u{1FB4B}":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"\u{1FB4C}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4D}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4E}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB4F}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB50}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"\u{1FB51}":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB52}":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB53}":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"\u{1FB54}":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB55}":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"\u{1FB56}":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"\u{1FB57}":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"\u{1FB58}":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"\u{1FB59}":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"\u{1FB5A}":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"\u{1FB5B}":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"\u{1FB5C}":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB5D}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5E}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5F}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB60}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB61}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"\u{1FB62}":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"\u{1FB63}":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"\u{1FB64}":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"\u{1FB65}":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"\u{1FB66}":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"\u{1FB67}":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"\u{1FB68}":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"\u{1FB69}":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"\u{1FB6A}":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"\u{1FB6B}":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"\u{1FB6C}":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"\u{1FB6D}":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"\u{1FB6E}":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"\u{1FB6F}":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"\u{1FB70}":{type:0,data:[{x:1,y:0,w:1,h:8}]},"\u{1FB71}":{type:0,data:[{x:2,y:0,w:1,h:8}]},"\u{1FB72}":{type:0,data:[{x:3,y:0,w:1,h:8}]},"\u{1FB73}":{type:0,data:[{x:4,y:0,w:1,h:8}]},"\u{1FB74}":{type:0,data:[{x:5,y:0,w:1,h:8}]},"\u{1FB75}":{type:0,data:[{x:6,y:0,w:1,h:8}]},"\u{1FB76}":{type:0,data:[{x:0,y:1,w:8,h:1}]},"\u{1FB77}":{type:0,data:[{x:0,y:2,w:8,h:1}]},"\u{1FB78}":{type:0,data:[{x:0,y:3,w:8,h:1}]},"\u{1FB79}":{type:0,data:[{x:0,y:4,w:8,h:1}]},"\u{1FB7A}":{type:0,data:[{x:0,y:5,w:8,h:1}]},"\u{1FB7B}":{type:0,data:[{x:0,y:6,w:8,h:1}]},"\u{1FB7C}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB7D}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7E}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7F}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB80}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB81}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB82}":{type:0,data:[{x:0,y:0,w:8,h:2}]},"\u{1FB83}":{type:0,data:[{x:0,y:0,w:8,h:3}]},"\u{1FB84}":{type:0,data:[{x:0,y:0,w:8,h:5}]},"\u{1FB85}":{type:0,data:[{x:0,y:0,w:8,h:6}]},"\u{1FB86}":{type:0,data:[{x:0,y:0,w:8,h:7}]},"\u{1FB87}":{type:0,data:[{x:6,y:0,w:2,h:8}]},"\u{1FB88}":{type:0,data:[{x:5,y:0,w:3,h:8}]},"\u{1FB89}":{type:0,data:[{x:3,y:0,w:5,h:8}]},"\u{1FB8A}":{type:0,data:[{x:2,y:0,w:6,h:8}]},"\u{1FB8B}":{type:0,data:[{x:1,y:0,w:7,h:8}]},"\u{1FB8C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"\u{1FB8D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"\u{1FB8E}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"\u{1FB8F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"\u{1FB90}":{type:1,data:[[0,1],[1,0]]},"\u{1FB91}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"\u{1FB92}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"\u{1FB94}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"\u{1FB95}":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"\u{1FB96}":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"\u{1FB97}":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"\u{1FB98}":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"\u{1FB99}":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"\u{1FB9A}":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"\u{1FB9B}":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"\u{1FB9C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"\u{1FB9D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"\u{1FB9E}":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"\u{1FB9F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"\u{1FBA0}":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"\u{1FBA1}":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"\u{1FBA2}":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"\u{1FBA3}":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"\u{1FBA4}":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBA5}":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBA6}":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBA7}":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"\u{1FBA8}":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"\u{1FBA9}":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"\u{1FBAA}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"\u{1FBAB}":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBAC}":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBAD}":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBAE}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"\u{1FBAF}":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"\u{1FBB0}":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"\u{1FBB1}":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"\u{1FBB2}":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"\u{1FBB3}":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"\u{1FBB4}":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"\u{1FBB5}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB6}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB7}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB8}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB9}":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"\u{1FBBA}":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"\u{1FBBB}":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"\u{1FBBC}":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"\u{1FBBD}":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"\u{1FBBE}":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"\u{1FBBF}":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"\u{1FBC0}":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"\u{1FBC1}":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"\u{1FBC2}":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"\u{1FBC3}":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"\u{1FBC4}":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"\u{1FBC5}":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC6}":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC7}":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC8}":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC9}":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBCA}":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"\u{1FBCB}":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"\u{1FBCC}":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"\u{1FBCD}":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"\u{1FBCE}":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"\u{1FBCF}":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"\u{1FBD0}":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"\u{1FBD1}":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"\u{1FBD2}":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"\u{1FBD3}":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"\u{1FBD4}":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"\u{1FBD5}":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"\u{1FBD6}":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"\u{1FBD7}":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"\u{1FBD8}":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"\u{1FBD9}":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDA}":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDB}":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"\u{1FBDC}":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"\u{1FBDD}":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"\u{1FBDE}":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"\u{1FBDF}":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"\u{1FBE0}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"\u{1FBE1}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"\u{1FBE2}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"\u{1FBE3}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"\u{1FBE4}":{type:0,data:[{x:2,y:0,w:4,h:4}]},"\u{1FBE5}":{type:0,data:[{x:2,y:4,w:4,h:4}]},"\u{1FBE6}":{type:0,data:[{x:0,y:2,w:4,h:4}]},"\u{1FBE7}":{type:0,data:[{x:4,y:2,w:4,h:4}]},"\u{1FBE8}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"\u{1FBE9}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"\u{1FBEA}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"\u{1FBEB}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"\u{1FBEC}":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"\u{1FBED}":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"\u{1FBEE}":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"\u{1FBEF}":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"\u{1FBF0}":{type:5,data:{d:o0(126),type:0},scaleType:1},"\u{1FBF1}":{type:5,data:{d:o0(48),type:0},scaleType:1},"\u{1FBF2}":{type:5,data:{d:o0(109),type:0},scaleType:1},"\u{1FBF3}":{type:5,data:{d:o0(121),type:0},scaleType:1},"\u{1FBF4}":{type:5,data:{d:o0(51),type:0},scaleType:1},"\u{1FBF5}":{type:5,data:{d:o0(91),type:0},scaleType:1},"\u{1FBF6}":{type:5,data:{d:o0(95),type:0},scaleType:1},"\u{1FBF7}":{type:5,data:{d:o0(114),type:0},scaleType:1},"\u{1FBF8}":{type:5,data:{d:o0(127),type:0},scaleType:1},"\u{1FBF9}":{type:5,data:{d:o0(123),type:0},scaleType:1},"\u{1FBFA}":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(i,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},$e=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951]);function f(i){return{type:2,data:()=>{let e=[],r=[.375,.25,.375],s=[0,3/8,5/8];for(let a=0;a<3;a++){let o=i>>a*2&1,n=i>>a*2+1&1;o&&n?e.push(`M0,${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L0,${s[a]+r[a]} Z`):o?e.push(`M0,${s[a]} L${.5},${s[a]} L${.5},${s[a]+r[a]} L0,${s[a]+r[a]} Z`):n&&e.push(`M${.5},${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L${.5},${s[a]+r[a]} Z`)}return e.join(" ")}}}function o0(i){let e=[];if(i&64){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.2125} L${Q+.075},${.175} L${m-.075},${.175} L${m},${.2125} L${m-.075},${.25} L${Q+.075},${.25} Z`)}if(i&32){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.175+.075+.015,m=.5-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&16){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.5+.015,m=.825-.075-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&8){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.7875} L${Q+.075},${.75} L${m-.075},${.75} L${m},${.7875} L${m-.075},${.825} L${Q+.075},${.825} Z`)}if(i&4&&e.push(`M${.125},${.515} L${.2},${.515+.0375} L${.2},${.735-.0375} L${.125},${.735} L${.05},${.735-.0375} L${.05},${.515+.0375} Z`),i&2&&e.push(`M${.125},${.265} L${.2},${.265+.0375} L${.2},${.485-.0375} L${.125},${.485} L${.05},${.485-.0375} L${.05},${.265+.0375} Z`),i&1){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.5} L${Q+.075},${.4625} L${m-.075},${.4625} L${m},${.5} L${m-.075},${.5375} L${Q+.075},${.5375} Z`)}return e.join(" ")}var k=0,H=0,T0=!1,_0=!1,$0=!1,z,x0=0,G0=class{constructor(e,t,r,s,a,o){this._terminal=e;this._optionService=t;this._selectionRenderModel=r;this._decorationService=s;this._coreBrowserService=a;this._themeService=o;this.result={fg:0,bg:0,ext:0}}resolve(e,t,r,s,a){this.result.bg=e.bg,this.result.fg=e.fg,this.result.ext=e.bg&268435456?e.extended.ext:0,H=0,k=0,_0=!1,T0=!1,$0=!1,z=this._themeService.colors,x0=0;let o=e.getCode();if(o!==0&&e.extended.underlineStyle===4){let n=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));x0=t*s%(Math.round(n)*2)}if(x0===0&&$e.has(o)&&(x0=t*s%2*2+r*a%2),this._decorationService.forEachDecorationAtCell(t,r,"bottom",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),$0=this._selectionRenderModel.isCellSelected(this._terminal,t,r),$0){if(this.result.fg&67108864||(this.result.bg&50331648)!==0){if(this.result.fg&67108864)switch(this.result.fg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:H=(this.result.fg&16777215)<<8|255;break;case 0:default:H=this._themeService.colors.foreground.rgba}else switch(this.result.bg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:H=(this.result.bg&16777215)<<8|255;break}H=d0.blend(H,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}else H=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(_0=!0,z.selectionForeground&&(k=z.selectionForeground.rgba>>8&16777215,T0=!0),V0(e.getCode())){if(this.result.fg&67108864&&(this.result.bg&50331648)===0)k=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(this.result.fg&67108864)switch(this.result.bg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:k=(this.result.bg&16777215)<<8|255;break}else switch(this.result.fg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:k=(this.result.fg&16777215)<<8|255;break;case 0:default:k=this._themeService.colors.foreground.rgba}k=d0.blend(k,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}T0=!0}}this._decorationService.forEachDecorationAtCell(t,r,"top",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),_0&&($0?H=e.bg&-16777216&-134217729|H|50331648:H=e.bg&-16777216|H|50331648),T0&&(k=e.fg&-16777216&-67108865|k|50331648),this.result.fg&67108864&&(_0&&!T0&&((this.result.bg&50331648)===0?k=this.result.fg&-134217728|z.background.rgba>>8&16777215&16777215|50331648:k=this.result.fg&-134217728|this.result.bg&67108863,T0=!0),!_0&&T0&&((this.result.fg&50331648)===0?H=this.result.bg&-67108864|z.foreground.rgba>>8&16777215&16777215|50331648:H=this.result.bg&-67108864|this.result.fg&67108863,_0=!0)),z=void 0,this.result.bg=_0?H:this.result.bg,this.result.fg=T0?k:this.result.fg,this.result.ext&=536870911,this.result.ext|=x0<<29&3758096384}};var Ke=.5,K0=Be||we?"bottom":"ideographic";function qe(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=Ve[e];if(p){let c=Array.isArray(p)?p:[p];for(let u of c)Pt(i,u,t,r,s,a,o,n,L,T,h,d,_);return!0}return!1}function Pt(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=s,c=a,u=t,C=r;switch(e.scaleType===1&&(p=o,c=n,u=t+(s-o)/2,C=r+(a-n)/2),e.clipPath&&(i.save(),Vt(i,e.clipPath,u,C,p,c)),e.type){case 0:Bt(i,e.data,u,C,p,c);break;case 1:Ut(i,e.data,u,C,p,c,_);break;case 2:Wt(i,e.data,u,C,p,c,T,h,e.strokeWidth);break;case 3:Ht(i,e.data,u,C,p,c,T,e.strokeWidth);break;case 4:Zt(i,e.data,u,C,p,c,T,d);break;case 5:$t(i,e.data,u,C,p,c,L,T,h);break;case 6:wt(i,e.data,u,C,p,c);break}e.clipPath&&i.restore()}function Bt(i,e,t,r,s,a){for(let o=0;o{let F=parseFloat(m);return R%2===0?t+F*s:r+F*a});if(u==="M")i.moveTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="L")i.lineTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="Q")i.quadraticCurveTo(Q[0],Q[1],Q[2],Q[3]),d=Q[0],_=Q[1],T=Q[2],h=Q[3];else if(u==="T"){let m,R;p==="Q"||p==="T"?(m=2*T-d,R=2*h-_):(m=T,R=h),i.quadraticCurveTo(m,R,Q[0],Q[1]),d=m,_=R,T=Q[0],h=Q[1]}else u==="C"&&(i.bezierCurveTo(Q[0],Q[1],Q[2],Q[3],Q[4],Q[5]),d=Q[2],_=Q[3],T=Q[4],h=Q[5]);p=u}n!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*n,i.stroke()):i.fill()}function kt(i,e,t,r,s,a,o,n,L,T){if(r===0||s===0){i.lineTo(L,T);return}r=Math.abs(r),s=Math.abs(s);let h=Math.cos(a),d=Math.sin(a),_=(e-L)/2,p=(t-T)/2,c=h*_+d*p,u=-d*_+h*p,C=r*r,Q=s*s,m=c*c,R=u*u,F=m/C+R/Q;if(F>1){let I=Math.sqrt(F);r*=I,s*=I,C=r*r,Q=s*s}let E=(C*Q-C*R-Q*m)/(C*R+Q*m);E<0&&(E=0);let y=(o===n?-1:1)*Math.sqrt(E),Y=y*(r*u/s),X=y*-(s*c/r),A0=h*Y-d*X+(e+L)/2,t0=d*Y+h*X+(t+T)/2,L0=(c-Y)/r,A=(u-X)/s,n0=(-c-Y)/r,a0=(-u-X)/s,$=Math.atan2(A,L0),q=Math.atan2(a0,n0)-$;n===0&&q>0?q-=2*Math.PI:n===1&&q<0&&(q+=2*Math.PI);let g0=$+q;i.ellipse(A0,t0,r,s,a,$,g0,n===0)}function Zt(i,e,t,r,s,a,o,n){i.save(),i.fillRect(t,r,s,a),n&&(i.fillStyle=n,i.strokeStyle=n),i.lineWidth=o,i.lineCap="square",i.beginPath();for(let L of e.d.split(" ")){let T=L[0],h=L.substring(1).split(",");if(!h[0]||!h[1]){T==="Z"&&i.closePath();continue}let d=h.map((_,p)=>{let c=parseFloat(_);return p%2===0?t+c*s:r+c*a});T==="M"?i.moveTo(d[0],d[1]):T==="L"&&i.lineTo(d[0],d[1])}e.type===1?i.stroke():i.fill(),i.restore()}var Xe=new Map;function Ut(i,e,t,r,s,a,o=0){let n=Xe.get(e);n||(n=new Map,Xe.set(e,n));let L=i.fillStyle;if(typeof L!="string")throw new Error(`Unexpected fillStyle type "${L}"`);let T=n.get(L);if(!T){let _=e[0].length,p=e.length,c=i.canvas.ownerDocument.createElement("canvas");c.width=_,c.height=p;let u=g(c.getContext("2d")),C=new ImageData(_,p),Q,m,R,F;if(L.startsWith("#"))Q=parseInt(L.slice(1,3),16),m=parseInt(L.slice(3,5),16),R=parseInt(L.slice(5,7),16),F=L.length>7&&parseInt(L.slice(7,9),16)||1;else if(L.startsWith("rgba"))[Q,m,R,F]=L.substring(5,L.length-1).split(",").map(E=>parseFloat(E));else throw new Error(`Unexpected fillStyle color format "${L}" when drawing pattern glyph`);for(let E=0;E>1&1,d=o&1;h!==0||d!==0?T.setTransform(new DOMMatrix().translateSelf(-h,-d)):T.setTransform(new DOMMatrix),i.fillStyle=T,i.fillRect(t,r,s,a)}function Wt(i,e,t,r,s,a,o,n,L){i.save(),i.beginPath(),i.rect(t,r,s,a),i.clip(),i.beginPath();let T;if(typeof e=="function"){let _=.15/a*s;T=e(.15,_)}else T=e;let h={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let d of T.split(" ")){let _=d[0];if(_==="Z"){i.closePath(),h.lastCommand=_;continue}let p=je[_];if(!p){n.error(`Could not find drawing instructions for "${_}"`);continue}let c=d.substring(1).split(",");!c[0]||!c[1]||(p(i,Je(c,s,a,t,r,!0,o,0,0,!1),h),h.lastCommand=_)}L!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*L,i.stroke()):i.fill(),i.closePath(),i.restore()}function Vt(i,e,t,r,s,a){i.beginPath();for(let o of e.split(" ")){let n=o[0];if(n==="Z"){i.closePath();continue}let L=o.substring(1).split(",");if(!L[0]||!L[1])continue;let T=t+parseFloat(L[0])*s,h=r+parseFloat(L[1])*a;n==="M"?i.moveTo(T,h):n==="L"&&i.lineTo(T,h)}i.clip()}function $t(i,e,t,r,s,a,o,n,L){let T=new Path2D;T.rect(t,r,s,a),i.clip(T),i.beginPath();let h=o/12;i.lineWidth=n*h;let d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let _ of e.d.split(" ")){let p=_[0];if(p==="Z"){i.closePath(),d.lastCommand=p;continue}let c=je[p];if(!c){L.error(`Could not find drawing instructions for "${p}"`);continue}let u=_.substring(1).split(",");!u[0]||!u[1]||(c(i,Je(u,s,a,t,r,!1,n,(e.leftPadding??0)*(h/2),(e.rightPadding??0)*(h/2)),d),d.lastCommand=p)}e.type===1?(i.strokeStyle=i.fillStyle,i.stroke()):i.fill(),i.closePath()}function Ye(i,e,t=0){return Math.max(Math.min(i,e),t)}var je={C:(i,e,t)=>{i.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),t.lastControlX=e[2],t.lastControlY=e[3],t.currentX=e[4],t.currentY=e[5]},L:(i,e,t)=>{i.lineTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},M:(i,e,t)=>{i.moveTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},Q:(i,e,t)=>{i.quadraticCurveTo(e[0],e[1],e[2],e[3]),t.lastControlX=e[0],t.lastControlY=e[1],t.currentX=e[2],t.currentY=e[3]},T:(i,e,t)=>{let r,s;t.lastCommand==="Q"||t.lastCommand==="T"?(r=2*t.currentX-t.lastControlX,s=2*t.currentY-t.lastControlY):(r=t.currentX,s=t.currentY),i.quadraticCurveTo(r,s,e[0],e[1]),t.lastControlX=r,t.lastControlY=s,t.currentX=e[0],t.currentY=e[1]}};function Je(i,e,t,r,s,a,o,n=0,L=0,T=!0){let h=i.map(d=>parseFloat(d)||parseInt(d));if(h.length<2)throw new Error("Too few arguments for instruction");for(let d=0;da){s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=a}this.clear()}},Ce=class extends X0{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Qe=class extends X0{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},et="requestIdleCallback"in globalThis?Qe:Ce;var s0=class i{constructor(){this.fg=0;this.bg=0;this.extended=new P0}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},P0=class i{constructor(e=0,t=0){this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new i(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var U=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(e,t,r)=>{if(this._disposed)return M(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let a=M(()=>{let o=this._listeners.indexOf(s);o!==-1&&this._listeners.splice(o,1)});return r&&(Array.isArray(r)?r.push(a):r.add(a)),a},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:r}=this._listeners[0];t.call(r,e);return}default:{let t=this._listeners.slice();for(let{fn:r,thisArgs:s}of t)r.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},c0;(s=>{function i(a,o){return a(n=>o.fire(n))}s.forward=i;function e(a,o){return(n,L,T)=>a(h=>n.call(L,o(h)),void 0,T)}s.map=e;function t(...a){return(o,n,L)=>{let T=new R0;for(let h of a)T.add(h(d=>o.call(n,d)));return L&&(Array.isArray(L)?L.push(T):L.add(T)),T}}s.any=t;function r(a,o,n){return o(n),a(L=>o(L))}s.runAndSubscribe=r})(c0||={});var tt={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},B0=2;var w0,J=class i{constructor(e,t,r,s){this._document=e;this._config=t;this._unicodeService=r;this._logService=s;this._didWarmUp=!1;this._cacheMap=new D0;this._cacheMapCombined=new D0;this._pages=[];this._activePages=[];this._workBoundingBox={top:0,left:0,bottom:0,right:0};this._workAttributeData=new s0;this._textureSize=512;this._onAddTextureAtlasCanvas=new U;this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=new U;this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._requestClearModel=!1;this._clearModelGeneration=0;this._createNewPage(),this._tmpCanvas=it(e,this._config.deviceCellWidth*4+B0*2,this._config.deviceCellHeight+B0*2),this._tmpCtx=g(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){let e=new et(this._logService);for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let r=this._drawToCache(t,0,0,0,!1,void 0);this._cacheMap.set(t,0,0,0,r)}})}beginFrame(){let e=this._requestClearModel;return this._requestClearModel=!1,e}clearTexture(){if(!(this._pages[0].currentRow.x===0&&this._pages[0].currentRow.y===0)){for(let e of this._pages)e.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1,this._requestClearModel=!0,this._clearModelGeneration++}}_createNewPage(){if(i.maxAtlasPages&&this._pages.length>=Math.max(4,i.maxAtlasPages)){let t=this._pages.filter(T=>T.canvas.width*2<=(i.maxTextureSize||4096)).sort((T,h)=>h.canvas.width!==T.canvas.width?h.canvas.width-T.canvas.width:h.percentageUsed-T.percentageUsed),r=-1,s=0;for(let T=0;TT.canvas.width!==a[0].canvas.width)){let T=new u0(this._document,this._textureSize);return this._pages.push(T),this._activePages.push(T),this._onAddTextureAtlasCanvas.fire(T.canvas),T}let o=a.map(T=>T.glyphs[0].texturePage).sort((T,h)=>T-h),n=this.pages.length-a.length,L=this._mergePages(a,n);L.version=++u0.nextVersion;for(let T=o.length-1;T>=0;T--)this._deletePage(o[T]);this.pages.push(L),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(L.canvas)}let e=new u0(this._document,this._textureSize);return this._pages.push(e),this._activePages.push(e),this._onAddTextureAtlasCanvas.fire(e.canvas),e}_mergePages(e,t){let r=e[0].canvas.width*2,s=new u0(this._document,r,e);for(let[a,o]of e.entries()){let n=a*o.canvas.width%r,L=Math.floor(a/2)*o.canvas.height;s.ctx.drawImage(o.canvas,n,L);for(let h of o.glyphs)h.texturePage=t,h.sizeClipSpace.x=h.size.x/r,h.sizeClipSpace.y=h.size.y/r,h.texturePosition.x+=n,h.texturePosition.y+=L,h.texturePositionClipSpace.x=h.texturePosition.x/r,h.texturePositionClipSpace.y=h.texturePosition.y/r;this._onRemoveTextureAtlasCanvas.fire(o.canvas);let T=this._activePages.indexOf(o);T!==-1&&this._activePages.splice(T,1)}return s}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw new Error("No color found for idx "+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,r,s){if(this._config.allowTransparency)return K;let a;switch(e){case 16777216:case 33554432:a=this._getColorFromAnsiIndex(t);break;case 50331648:let o=s0.toColorRGB(t);a=Z.toColor(o[0],o[1],o[2]);break;case 0:default:r?a=I0.opaque(this._config.colors.foreground):a=this._config.colors.background;break}return this._config.allowTransparency||(a=I0.opaque(a)),a}_getForegroundColor(e,t,r,s,a,o,n,L,T,h){let d=this._getMinimumContrastColor(e,t,r,s,a,o,n,T,L,h);if(d)return d;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&T&&o<8&&(o+=8),_=this._getColorFromAnsiIndex(o);break;case 50331648:let p=s0.toColorRGB(o);_=Z.toColor(p[0],p[1],p[2]);break;case 0:default:n?_=this._config.colors.background:_=this._config.colors.foreground}return this._config.allowTransparency&&(_=I0.opaque(_)),L&&(_=I0.multiplyOpacity(_,Ke)),_}_resolveBackgroundRgba(e,t,r){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,r,s){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,r,s,a,o,n,L,T,h){if(this._config.minimumContrastRatio===1||h)return;let d=this._getContrastCache(T),_=d.getColor(e,s);if(_!==void 0)return _??void 0;let p=this._resolveBackgroundRgba(t,r,n),c=this._resolveForegroundRgba(a,o,n,L),u=d0.ensureContrastRatio(p,c,this._config.minimumContrastRatio/(T?2:1));if(!u){d.setColor(e,s,null);return}let C=Z.toColor(u>>24&255,u>>16&255,u>>8&255);return d.setColor(e,s,C),C}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(e,t,r,s,a,o){let n=typeof e=="number"?String.fromCharCode(e):e;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display="none",o.append(this._tmpCanvas));let L=Math.min(this._config.deviceCellWidth*Math.max(n.length,2)+B0*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width=b?b*2-f0:b-f0;!(f0>=b)===!1||Z0===0?(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j+Z0,G),this._tmpCtx.lineTo(Q0,G)):(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(j+Z0,G),this._tmpCtx.moveTo(j+Z0+b,G),this._tmpCtx.lineTo(Q0,G)),f0=We(Q0-j,b,f0);break;case 5:let vt=.6,Ot=.3,de=Q0-j,Se=Math.floor(vt*de),Re=Math.floor(Ot*de),It=de-Se-Re;this._tmpCtx.setLineDash([Se,Re,It]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break;case 1:default:this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break}y0||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!n0&&this._config.fontSize>=12&&!this._config.allowTransparency&&n!==" "){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";let r0=this._tmpCtx.measureText(n);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in r0&&r0.actualBoundingBoxDescent>0){this._tmpCtx.save();let y0=new Path2D;y0.rect(i0,C0-Math.ceil(b/2),this._config.deviceCellWidth*$,v0-C0+Math.ceil(b/2)),this._tmpCtx.clip(y0),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=y.css,this._tmpCtx.strokeText(n,A,A+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(Q){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+D),this._tmpCtx.stroke()}if(n0||this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight),n==="_"&&!this._config.allowTransparency){let b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0);if(b)for(let D=1;D<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=y.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight-D),b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0),!!b);D++);}if(C){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),D=this._tmpCtx.lineWidth%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.stroke()}this._tmpCtx.restore();let q=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),g0;if(this._config.allowTransparency?g0=Gt(q):g0=fe(q,y,L0,a0),g0)return tt;let I=this._findGlyphBoundingBox(q,this._workBoundingBox,L,t0,n0,A),O,v;for(;;){if(this._activePages.length===0){let b=this._createNewPage();O=b,v=b.currentRow,v.height=I.size.y;break}O=this._activePages[this._activePages.length-1],v=O.currentRow;for(let b of this._activePages)I.size.y<=b.currentRow.height&&(O=b,v=b.currentRow);for(let b=this._activePages.length-1;b>=0;b--)for(let D of this._activePages[b].fixedRows)D.height<=v.height&&I.size.y<=D.height&&(O=this._activePages[b],v=D);if(I.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new u0(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),O=this._overflowSizePage,v=this._overflowSizePage.currentRow,v.x+I.size.x>=O.canvas.width&&(v.x=0,v.y+=v.height,v.height=0);break}if(v.y+I.size.y>=O.canvas.height||v.height>I.size.y+2){let b=!1;if(O.currentRow.y+O.currentRow.height+I.size.y>=O.canvas.height){let D;for(let i0 of this._activePages)if(i0.currentRow.y+i0.currentRow.height+I.size.y=i.maxAtlasPages&&v.y+I.size.y<=O.canvas.height&&v.height>=I.size.y&&v.x+I.size.x<=O.canvas.width)b=!0;else{let i0=this._createNewPage();O=i0,v=i0.currentRow,v.height=I.size.y,b=!0}}b||(O.currentRow.height>0&&O.fixedRows.push(O.currentRow),v={x:0,y:O.currentRow.y+O.currentRow.height,height:I.size.y},O.fixedRows.push(v),O.currentRow={x:0,y:v.y+v.height,height:0})}if(v.x+I.size.x<=O.canvas.width)break;v===O.currentRow?(v.x=0,v.y+=v.height,v.height=0):O.fixedRows.splice(O.fixedRows.indexOf(v),1)}return I.texturePage=this._pages.indexOf(O),I.texturePosition.x=v.x,I.texturePosition.y=v.y,I.texturePositionClipSpace.x=v.x/O.canvas.width,I.texturePositionClipSpace.y=v.y/O.canvas.height,I.sizeClipSpace.x/=O.canvas.width,I.sizeClipSpace.y/=O.canvas.height,v.height=Math.max(v.height,I.size.y),v.x+=I.size.x,O.ctx.putImageData(q,I.texturePosition.x-this._workBoundingBox.left,I.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,I.size.x,I.size.y),O.addGlyph(I),O.version=++u0.nextVersion,I}_findGlyphBoundingBox(e,t,r,s,a,o){t.top=0;let n=s?this._config.deviceCellHeight:this._tmpCanvas.height,L=s?this._config.deviceCellWidth:r,T=!1;for(let h=0;h=o;h--){for(let d=0;d=0;h--){for(let d=0;d>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=t.rgba>>>24,L=t.rgba>>>16&255,T=t.rgba>>>8&255,h=Math.floor((Math.abs(s-n)+Math.abs(a-L)+Math.abs(o-T))/12),d=!0;for(let _=0;_0)return!1;return!0}function it(i,e,t){let r=i.createElement("canvas");return r.width=e,r.height=t,r}function rt(i,e,t,r,s,a,o,n,L=!0){let T={foreground:a.foreground,background:a.background,cursor:K,cursorAccent:K,selectionForeground:K,selectionBackgroundTransparent:K,selectionBackgroundOpaque:K,selectionInactiveBackgroundTransparent:K,selectionInactiveBackgroundOpaque:K,overviewRulerBorder:K,scrollbarSliderBackground:K,scrollbarSliderHoverBackground:K,scrollbarSliderActiveBackground:K,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:L,devicePixelRatio:o,deviceMaxTextureSize:n,letterSpacing:s.letterSpacing,lineHeight:s.lineHeight,deviceCellWidth:i,deviceCellHeight:e,deviceCharWidth:t,deviceCharHeight:r,fontFamily:s.fontFamily,fontSize:s.fontSize,fontWeight:s.fontWeight,fontWeightBold:s.fontWeightBold,allowTransparency:s.allowTransparency,drawBoldTextInBrightColors:s.drawBoldTextInBrightColors,minimumContrastRatio:s.minimumContrastRatio,colors:T}}function Ee(i,e){for(let t=0;t=0){if(Ee(u.config,h))return u.atlas;u.ownedBy.length===1?(u.atlas.dispose(),e0.splice(c,1)):u.ownedBy.splice(C,1);break}}for(let c=0;c{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(e=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0){this._restartInterval(t);return}}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(t);return}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},e)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}};function ve(i,e,t){let r=new e.ResizeObserver(s=>{let a=s.find(L=>L.target===i);if(!a)return;if(!("devicePixelContentBoxSize"in a)){r?.disconnect(),r=void 0;return}let o=a.devicePixelContentBoxSize[0].inlineSize,n=a.devicePixelContentBoxSize[0].blockSize;o>0&&n>0&&t(o,n)});try{r.observe(i,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return M(()=>r?.disconnect())}function nt(i){return i>65535?(i-=65536,String.fromCharCode((i>>10)+55296)+String.fromCharCode(i%1024+56320)):String.fromCharCode(i)}var j0=class i extends s0{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new P0;this.combinedData=""}static fromCharData(t){let r=new i;return r.setFromCharData(t),r}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?nt(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){this.fg=t[0],this.bg=0;let r=!1;if(t[1].length>2)r=!0;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let a=t[1].charCodeAt(1);56320<=a&&a<=57343?this.content=(s-55296)*1024+a-56320+65536|t[2]<<22:r=!0}else r=!0}else this.content=t[1].charCodeAt(0)|t[2]<<22;r&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;let r=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(r&&s)&&(r!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return!1}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}};var J0=class extends W{constructor(t,r,s){super();this._renderCallback=t;this._coreBrowserService=r;this._optionsService=s;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",a=>{this.setIntervalDuration(a)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(M(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let r=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),r||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var te=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function ie(i,e,t,r){let s=g(i.createProgram());if(i.attachShader(s,g(at(i,i.VERTEX_SHADER,e,r))),i.attachShader(s,g(at(i,i.FRAGMENT_SHADER,t,r))),i.linkProgram(s),i.getProgramParameter(s,i.LINK_STATUS))return s;r.error(i.getProgramInfoLog(s)),i.deleteProgram(s)}function at(i,e,t,r){let s=g(i.createShader(e));if(i.shaderSource(s,t),i.compileShader(s),i.getShaderParameter(s,i.COMPILE_STATUS))return s;r.error(i.getShaderInfoLog(s)),i.deleteShader(s)}function ot(i,e){let t=Math.min(i.length*2,e),r=new Float32Array(t);for(let s=0;sFt(i))}var R0=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(let e of this._disposables)e.dispose();this._disposables.clear()}},W=class{constructor(){this._store=new R0}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}};W.None=Object.freeze({dispose(){}});var m0=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var Pe=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),O0=Pe?"node":navigator.userAgent,ce=Pe?"node":navigator.platform,Be=O0.includes("Firefox"),n1=O0.includes("Chrome"),we=O0.includes("Edge"),ue=/^((?!chrome|android).)*safari/i.test(O0);function He(){if(!ue)return 0;let i=O0.match(/Version\/(\d+)/);return i===null||i.length<2?0:parseInt(i[1],10)}var a1=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(ce),o1=["Windows","Win16","Win32","WinCE"].includes(ce),T1=ce.indexOf("Linux")>=0,l1=/\bCrOS\b/.test(O0);var P=0,B=0,w=0,x=0,K={css:"#00000000",rgba:0},Z;(r=>{function i(s,a,o,n){return n!==void 0?`#${E0(s)}${E0(a)}${E0(o)}${E0(n)}`:`#${E0(s)}${E0(a)}${E0(o)}`}r.toCss=i;function e(s,a,o,n=255){return(s<<24|a<<16|o<<8|n)>>>0}r.toRgba=e;function t(s,a,o,n){return{css:r.toCss(s,a,o,n),rgba:r.toRgba(s,a,o,n)}}r.toColor=t})(Z||={});var I0;(n=>{function i(L,T){if(x=(T.rgba&255)/255,x===1)return{css:T.css,rgba:T.rgba};let h=T.rgba>>24&255,d=T.rgba>>16&255,_=T.rgba>>8&255,p=L.rgba>>24&255,c=L.rgba>>16&255,u=L.rgba>>8&255;P=p+Math.round((h-p)*x),B=c+Math.round((d-c)*x),w=u+Math.round((_-u)*x);let C=Z.toCss(P,B,w),Q=Z.toRgba(P,B,w);return{css:C,rgba:Q}}n.blend=i;function e(L){return(L.rgba&255)===255}n.isOpaque=e;function t(L,T,h){let d=d0.ensureContrastRatio(L.rgba,T.rgba,h);if(d)return Z.toColor(d>>24&255,d>>16&255,d>>8&255)}n.ensureContrastRatio=t;function r(L){let T=(L.rgba|255)>>>0;return[P,B,w]=d0.toChannels(T),{css:Z.toCss(P,B,w),rgba:T}}n.opaque=r;function s(L,T){return x=Math.round(T*255),[P,B,w]=d0.toChannels(L.rgba),{css:Z.toCss(P,B,w,x),rgba:Z.toRgba(P,B,w,x)}}n.opacity=s;function a(L,T){return x=L.rgba&255,s(L,x*T/255)}n.multiplyOpacity=a;function o(L){return[L.rgba>>24&255,L.rgba>>16&255,L.rgba>>8&255]}n.toColorRGB=o})(I0||={});var Mt;(r=>{let i,e;try{let s=document.createElement("canvas");s.width=1,s.height=1;let a=s.getContext("2d",{willReadFrequently:!0});a&&(i=a,i.globalCompositeOperation="copy",e=i.createLinearGradient(0,0,1,1))}catch{}function t(s){if(s.match(/#[\da-f]{3,8}/i))switch(s.length){case 4:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),Z.toColor(P,B,w);case 5:return P=parseInt(s.slice(1,2).repeat(2),16),B=parseInt(s.slice(2,3).repeat(2),16),w=parseInt(s.slice(3,4).repeat(2),16),x=parseInt(s.slice(4,5).repeat(2),16),Z.toColor(P,B,w,x);case 7:return{css:s,rgba:(parseInt(s.slice(1),16)<<8|255)>>>0};case 9:return{css:s,rgba:parseInt(s.slice(1),16)>>>0}}let a=s.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return P=parseInt(a[1],10),B=parseInt(a[2],10),w=parseInt(a[3],10),x=Math.round((a[5]===void 0?1:parseFloat(a[5]))*255),Z.toColor(P,B,w,x);if(s==="transparent")return{css:"transparent",rgba:0};if(!i||!e)throw new Error("css.toColor: Unsupported css format");if(i.fillStyle=e,i.fillStyle=s,typeof i.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(i.fillRect(0,0,1,1),[P,B,w,x]=i.getImageData(0,0,1,1).data,x!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:Z.toRgba(P,B,w,x),css:s}}r.toColor=t})(Mt||={});var V;(t=>{function i(r){return e(r>>16&255,r>>8&255,r&255)}t.relativeLuminance=i;function e(r,s,a){let o=r/255,n=s/255,L=a/255,T=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),h=n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4),d=L<=.03928?L/12.92:Math.pow((L+.055)/1.055,2.4);return T*.2126+h*.7152+d*.0722}t.relativeLuminance2=e})(V||={});var d0;(a=>{function i(o,n){if(x=(n&255)/255,x===1)return n;let L=n>>24&255,T=n>>16&255,h=n>>8&255,d=o>>24&255,_=o>>16&255,p=o>>8&255;return P=d+Math.round((L-d)*x),B=_+Math.round((T-_)*x),w=p+Math.round((h-p)*x),Z.toRgba(P,B,w)}a.blend=i;function e(o,n,L){let T=V.relativeLuminance(o>>8),h=V.relativeLuminance(n>>8);if(h0(T,h)>8));if(u>8));return u>Q?c:C}return c}let _=r(o,n,L),p=h0(T,V.relativeLuminance(_>>8));if(p>8));return p>u?_:c}return _}}a.ensureContrastRatio=e;function t(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u0||p>0||c>0);)_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),c-=Math.max(0,Math.ceil(c*.1)),u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));return(_<<24|p<<16|c<<8|255)>>>0}a.reduceLuminance=t;function r(o,n,L){let T=o>>24&255,h=o>>16&255,d=o>>8&255,_=n>>24&255,p=n>>16&255,c=n>>8&255,u=h0(V.relativeLuminance2(_,p,c),V.relativeLuminance2(T,h,d));for(;u>>0}a.increaseLuminance=r;function s(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}a.toChannels=s})(d0||={});function E0(i){let e=i.toString(16);return e.length<2?"0"+e:e}function h0(i,e){return i=128512&&i<=128591||i>=127744&&i<=128511||i>=128640&&i<=128767||i>=9728&&i<=9983||i>=9984&&i<=10175||i>=65024&&i<=65039||i>=129280&&i<=129535||i>=127462&&i<=127487}function Ze(i,e,t,r){return e===1&&t>Math.ceil(r*1.5)&&i!==void 0&&i>255&&!yt(i)&&!W0(i)&&!At(i)}function V0(i){return W0(i)||gt(i)}function Ue(){return{css:{canvas:U0(),cell:U0()},device:{canvas:U0(),cell:U0(),char:{width:0,height:0,left:0,top:0}}}}function U0(){return{width:0,height:0}}function We(i,e,t=0){return(i-(Math.round(e)*2-t))%(Math.round(e)*2)}var l;(C=>(C.LINE_H=Object.freeze({type:2,data:"M0,.5 L1,.5",strokeWidth:1}),C.LINE_V=Object.freeze({type:2,data:"M.5,0 L.5,1",strokeWidth:1}),C.FADE_RIGHT=Object.freeze({type:2,data:"M0,.5 L.28,.5 M.32,.5 L.52,.5 M.60,.5 L.72,.5 M.84,.5 L.90,.5",strokeWidth:1}),C.FADE_LEFT=Object.freeze({type:2,data:"M.10,.5 L.16,.5 M.28,.5 L.40,.5 M.48,.5 L.68,.5 M.72,.5 L1,.5",strokeWidth:1}),C.FADE_DOWN=Object.freeze({type:2,data:"M.5,0 L.5,.28 M.5,.32 L.5,.52 M.5,.60 L.5,.72 M.5,.84 L.5,.90",strokeWidth:1}),C.FADE_UP=Object.freeze({type:2,data:"M.5,.10 L.5,.16 M.5,.28 L.5,.40 M.5,.48 L.5,.68 M.5,.72 L.5,1",strokeWidth:1}),C.CURVE_DOWN_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_DOWN_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,1 L.5,${.5+m/.15*.5} C.5,${.5+m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.CURVE_UP_RIGHT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,1,.5`,strokeWidth:1}),C.CURVE_UP_LEFT=Object.freeze({type:2,data:(Q,m)=>`M.5,0 L.5,${.5-m/.15*.5} C.5,${.5-m/.15*.5},.5,.5,0,.5`,strokeWidth:1}),C.NODE_FILL=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5"}),C.NODE_STROKE=Object.freeze({type:3,data:"M.85,.5 A.35,.175,0,1,1,.15,.5 A.35,.175,0,1,1,.85,.5",strokeWidth:1}),C.NODE_LINE_RIGHT=Object.freeze({type:3,data:"M1,.5 L.85,.5",strokeWidth:1}),C.NODE_LINE_LEFT=Object.freeze({type:3,data:"M0,.5 L.15,.5",strokeWidth:1}),C.NODE_LINE_DOWN=Object.freeze({type:3,data:"M.5,1 L.5,.7",strokeWidth:1}),C.NODE_LINE_UP=Object.freeze({type:3,data:"M.5,0 L.5,.3",strokeWidth:1})))(l||={});var Ve={"\u2500":{type:2,data:"M0,.5 L1,.5",strokeWidth:1},"\u2501":{type:2,data:"M0,.5 L1,.5",strokeWidth:3},"\u2502":{type:2,data:"M.5,0 L.5,1",strokeWidth:1},"\u2503":{type:2,data:"M.5,0 L.5,1",strokeWidth:3},"\u2504":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:1},"\u2505":{type:2,data:"M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5",strokeWidth:3},"\u2506":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:1},"\u2507":{type:2,data:"M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333",strokeWidth:3},"\u2508":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:1},"\u2509":{type:2,data:"M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5",strokeWidth:3},"\u250A":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:1},"\u250B":{type:2,data:"M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95",strokeWidth:3},"\u250C":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},"\u250D":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u250E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u250F":{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3},"\u2510":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},"\u2511":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2512":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2513":{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3},"\u2514":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},"\u2515":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2516":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2517":{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3},"\u2518":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},"\u2519":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u251A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251B":{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3},"\u251C":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},"\u251D":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u251E":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u251F":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2520":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2521":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2522":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2523":{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3},"\u2524":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},"\u2525":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2526":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2527":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2528":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2529":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u252A":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u252B":{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3},"\u252C":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},"\u252D":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u252E":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u252F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2530":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2531":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2532":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2533":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3},"\u2534":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:1},"\u2535":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u2536":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u2537":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2538":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2539":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u253A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u253B":{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,0",strokeWidth:3},"\u253C":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:1},"\u253D":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u253E":[{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u253F":[{type:2,data:"M.5,0 L.5,1",strokeWidth:1},{type:2,data:"M0,.5 L1,.5",strokeWidth:3}],"\u2540":[{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2541":[{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u2542":[{type:2,data:"M0,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1",strokeWidth:3}],"\u2543":[{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:3}],"\u2544":[{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:3}],"\u2545":[{type:2,data:"M.5,0 L.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M0,.5 L.5,.5 L.5,1",strokeWidth:3}],"\u2546":[{type:2,data:"M.5,0 L.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M0.5,1 L.5,.5 L1,.5",strokeWidth:3}],"\u2547":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0 M0,.5 L1,.5",strokeWidth:3}],"\u2548":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M0,.5 L1,.5 M.5,.5 L.5,1",strokeWidth:3}],"\u2549":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L0,.5",strokeWidth:3}],"\u254A":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,0 L.5,1 M.5,.5 L1,.5",strokeWidth:3}],"\u254B":{type:2,data:"M0,.5 L1,.5 M.5,0 L.5,1",strokeWidth:3},"\u254C":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:1},"\u254D":{type:2,data:"M.1,.5 L.4,.5 M.6,.5 L.9,.5",strokeWidth:3},"\u254E":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:1},"\u254F":{type:2,data:"M.5,.1 L.5,.4 M.5,.6 L.5,.9",strokeWidth:3},"\u2550":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2551":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2552":{type:2,data:(i,e)=>`M.5,1 L.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2553":{type:2,data:(i,e)=>`M${.5-i},1 L${.5-i},.5 L1,.5 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2554":{type:2,data:(i,e)=>`M1,${.5-e} L${.5-i},${.5-e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2555":{type:2,data:(i,e)=>`M0,${.5-e} L.5,${.5-e} L.5,1 M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2556":{type:2,data:(i,e)=>`M${.5+i},1 L${.5+i},.5 L0,.5 M${.5-i},.5 L${.5-i},1`,strokeWidth:1},"\u2557":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5+i},${.5-e} L${.5+i},1`,strokeWidth:1},"\u2558":{type:2,data:(i,e)=>`M.5,0 L.5,${.5+e} L1,${.5+e} M.5,${.5-e} L1,${.5-e}`,strokeWidth:1},"\u2559":{type:2,data:(i,e)=>`M1,.5 L${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u255A":{type:2,data:(i,e)=>`M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0 M1,${.5+e} L${.5-i},${.5+e} L${.5-i},0`,strokeWidth:1},"\u255B":{type:2,data:(i,e)=>`M0,${.5+e} L.5,${.5+e} L.5,0 M0,${.5-e} L.5,${.5-e}`,strokeWidth:1},"\u255C":{type:2,data:(i,e)=>`M0,.5 L${.5+i},.5 L${.5+i},0 M${.5-i},.5 L${.5-i},0`,strokeWidth:1},"\u255D":{type:2,data:(i,e)=>`M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M0,${.5+e} L${.5+i},${.5+e} L${.5+i},0`,strokeWidth:1},"\u255E":{type:2,data:(i,e)=>`M.5,0 L.5,1 M.5,${.5-e} L1,${.5-e} M.5,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u255F":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1 M${.5+i},.5 L1,.5`,strokeWidth:1},"\u2560":{type:2,data:(i,e)=>`M${.5-i},0 L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u2561":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L.5,${.5-e} M0,${.5+e} L.5,${.5+e}`,strokeWidth:1},"\u2562":{type:2,data:(i,e)=>`M0,.5 L${.5-i},.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u2563":{type:2,data:(i,e)=>`M${.5+i},0 L${.5+i},1 M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0`,strokeWidth:1},"\u2564":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e} M.5,${.5+e} L.5,1`,strokeWidth:1},"\u2565":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},1 M${.5+i},.5 L${.5+i},1`,strokeWidth:1},"\u2566":{type:2,data:(i,e)=>`M0,${.5-e} L1,${.5-e} M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1`,strokeWidth:1},"\u2567":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e} M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u2568":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},.5 L${.5-i},0 M${.5+i},.5 L${.5+i},0`,strokeWidth:1},"\u2569":{type:2,data:(i,e)=>`M0,${.5+e} L1,${.5+e} M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256A":{type:2,data:(i,e)=>`M.5,0 L.5,1 M0,${.5-e} L1,${.5-e} M0,${.5+e} L1,${.5+e}`,strokeWidth:1},"\u256B":{type:2,data:(i,e)=>`M0,.5 L1,.5 M${.5-i},0 L${.5-i},1 M${.5+i},0 L${.5+i},1`,strokeWidth:1},"\u256C":{type:2,data:(i,e)=>`M0,${.5+e} L${.5-i},${.5+e} L${.5-i},1 M1,${.5+e} L${.5+i},${.5+e} L${.5+i},1 M0,${.5-e} L${.5-i},${.5-e} L${.5-i},0 M1,${.5-e} L${.5+i},${.5-e} L${.5+i},0`,strokeWidth:1},"\u256D":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u256E":{type:2,data:(i,e)=>`M.5,1 L.5,${.5+e/.15*.5} C.5,${.5+e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u256F":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,0,.5`,strokeWidth:1},"\u2570":{type:2,data:(i,e)=>`M.5,0 L.5,${.5-e/.15*.5} C.5,${.5-e/.15*.5},.5,.5,1,.5`,strokeWidth:1},"\u2571":{type:2,data:"M1,0 L0,1",strokeWidth:1},"\u2572":{type:2,data:"M0,0 L1,1",strokeWidth:1},"\u2573":{type:2,data:"M1,0 L0,1 M0,0 L1,1",strokeWidth:1},"\u2574":{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},"\u2575":{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},"\u2576":{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},"\u2577":{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},"\u2578":{type:2,data:"M.5,.5 L0,.5",strokeWidth:3},"\u2579":{type:2,data:"M.5,.5 L.5,0",strokeWidth:3},"\u257A":{type:2,data:"M.5,.5 L1,.5",strokeWidth:3},"\u257B":{type:2,data:"M.5,.5 L.5,1",strokeWidth:3},"\u257C":[{type:2,data:"M.5,.5 L0,.5",strokeWidth:1},{type:2,data:"M.5,.5 L1,.5",strokeWidth:3}],"\u257D":[{type:2,data:"M.5,.5 L.5,0",strokeWidth:1},{type:2,data:"M.5,.5 L.5,1",strokeWidth:3}],"\u257E":[{type:2,data:"M.5,.5 L1,.5",strokeWidth:1},{type:2,data:"M.5,.5 L0,.5",strokeWidth:3}],"\u257F":[{type:2,data:"M.5,.5 L.5,1",strokeWidth:1},{type:2,data:"M.5,.5 L.5,0",strokeWidth:3}],"\u2580":{type:0,data:[{x:0,y:0,w:8,h:4}]},"\u2581":{type:0,data:[{x:0,y:7,w:8,h:1}]},"\u2582":{type:0,data:[{x:0,y:6,w:8,h:2}]},"\u2583":{type:0,data:[{x:0,y:5,w:8,h:3}]},"\u2584":{type:0,data:[{x:0,y:4,w:8,h:4}]},"\u2585":{type:0,data:[{x:0,y:3,w:8,h:5}]},"\u2586":{type:0,data:[{x:0,y:2,w:8,h:6}]},"\u2587":{type:0,data:[{x:0,y:1,w:8,h:7}]},"\u2588":{type:0,data:[{x:0,y:0,w:8,h:8}]},"\u2589":{type:0,data:[{x:0,y:0,w:7,h:8}]},"\u258A":{type:0,data:[{x:0,y:0,w:6,h:8}]},"\u258B":{type:0,data:[{x:0,y:0,w:5,h:8}]},"\u258C":{type:0,data:[{x:0,y:0,w:4,h:8}]},"\u258D":{type:0,data:[{x:0,y:0,w:3,h:8}]},"\u258E":{type:0,data:[{x:0,y:0,w:2,h:8}]},"\u258F":{type:0,data:[{x:0,y:0,w:1,h:8}]},"\u2590":{type:0,data:[{x:4,y:0,w:4,h:8}]},"\u2591":{type:1,data:[[1,0],[0,0]]},"\u2592":{type:1,data:[[1,0],[0,1]]},"\u2593":{type:1,data:[[1,1],[1,0]]},"\u2594":{type:0,data:[{x:0,y:0,w:8,h:1}]},"\u2595":{type:0,data:[{x:7,y:0,w:1,h:8}]},"\u2596":{type:0,data:[{x:0,y:4,w:4,h:4}]},"\u2597":{type:0,data:[{x:4,y:4,w:4,h:4}]},"\u2598":{type:0,data:[{x:0,y:0,w:4,h:4}]},"\u2599":{type:0,data:[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\u259A":{type:0,data:[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}]},"\u259B":{type:0,data:[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}]},"\u259C":{type:0,data:[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}]},"\u259D":{type:0,data:[{x:4,y:0,w:4,h:4}]},"\u259E":{type:0,data:[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}]},"\u259F":{type:0,data:[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}]},"\uE0A0":{type:5,data:{d:"M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655",type:0}},"\uE0A1":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0A2":{type:5,data:{d:"M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82",type:0}},"\uE0A3":{type:5,data:{d:"M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.7,.03 L.7,.1 L.355,.1 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5",type:0}},"\uE0B0":{type:5,data:{d:"M0,0 L1,.5 L0,1",type:0,rightPadding:2}},"\uE0B1":{type:5,data:{d:"M-1,-.5 L1,.5 L-1,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B2":{type:5,data:{d:"M1,0 L0,.5 L1,1",type:0,leftPadding:2}},"\uE0B3":{type:5,data:{d:"M2,-.5 L0,.5 L2,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0B4":{type:5,data:{d:"M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0",type:0,rightPadding:1}},"\uE0B5":{type:5,data:{d:"M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0",type:1,rightPadding:1}},"\uE0B6":{type:5,data:{d:"M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0",type:0,leftPadding:1}},"\uE0B7":{type:5,data:{d:"M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0",type:1,leftPadding:1}},"\uE0B8":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L-.5,1.5",type:0}},"\uE0B9":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BA":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L1.5,1.5",type:0}},"\uE0BB":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BC":{type:5,data:{d:"M1.5,-.5 L-.5,1.5 L-.5,-.5",type:0}},"\uE0BD":{type:5,data:{d:"M1.5,-.5 L-.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0BE":{type:5,data:{d:"M-.5,-.5 L1.5,1.5 L1.5,-.5",type:0}},"\uE0BF":{type:5,data:{d:"M-.5,-.5 L1.5,1.5",type:1,leftPadding:1,rightPadding:1}},"\uE0C0":{type:5,data:{d:"M0.8069,0.6075 Q0.7991,0.612,0.7815,0.6218 T0.7542,0.6369 T0.7282,0.6508 T0.7016,0.6644 T0.6778,0.6758 T0.6551,0.6854 T0.637,0.6911 T0.622,0.6936 Q0.6186,0.6936,0.6128,0.6942 T0.6018,0.6952 T0.5894,0.6956 T0.5757,0.6952 T0.561,0.693 T0.5451,0.6879 Q0.5342,0.6834,0.5193,0.6756 T0.4925,0.6614 T0.4673,0.6485 T0.4436,0.6389 T0.4239,0.6363 T0.4089,0.6422 Q0.4045,0.6467,0.3958,0.6554 T0.3843,0.6669 T0.3748,0.6746 T0.3613,0.683 T0.3442,0.6903 L0.3499,0.6956 Q0.3584,0.7042,0.3679,0.7177 T0.3836,0.7403 T0.4019,0.7583 T0.4295,0.7703 Q0.4688,0.7785,0.5203,0.7605 Q0.4902,0.7817,0.449,0.7911 T0.3696,0.8001 Q0.3567,0.7997,0.341,0.7942 T0.3111,0.7813 T0.2805,0.766 T0.2493,0.753 T0.218,0.7472 T0.187,0.754 Q0.1762,0.7589,0.1653,0.7681 T0.1453,0.7895 T0.1308,0.818 T0.1264,0.8507 L0.1264,0.858 Q0.1453,0.8462,0.1607,0.8433 T0.1899,0.8452 T0.2226,0.8625 Q0.2344,0.8707,0.2485,0.8752 T0.2813,0.8831 T0.3089,0.889 Q0.2981,0.8915,0.2846,0.8956 T0.2603,0.9037 T0.2368,0.9115 T0.2129,0.9174 T0.1894,0.9194 T0.1653,0.9155 Q0.1504,0.9115,0.1328,0.9196 T0.0986,0.9421 T0.0655,0.9708 T0.0317,0.9943 T0,1 Q0.001,0.94,0.0017,0.6508 T0.0024,0.2322 Q0.002,0.2109,0.0007,0.1379 T0,0.0249 Q0.0058,0.0253,0.009,0.0257 T0.0156,0.0288 T0.0203,0.0333 T0.0264,0.0422 T0.0349,0.0539 Q0.044,0.0657,0.0542,0.072 T0.0752,0.0789 T0.096,0.0775 T0.1175,0.0694 T0.1379,0.0573 T0.1579,0.0432 Q0.2141,0,0.2673,0.0318 Q0.2588,0.0318,0.2517,0.033 T0.239,0.0361 T0.2276,0.0424 T0.218,0.0504 T0.2085,0.0612 T0.1997,0.0732 T0.1899,0.0875 T0.1795,0.1028 Q0.1748,0.1093,0.1507,0.1234 T0.1186,0.1448 Q0.0999,0.1612,0.1108,0.1783 Q0.1162,0.1877,0.1238,0.1928 T0.1404,0.1979 T0.158,0.1967 T0.1775,0.1895 T0.1963,0.1791 T0.2151,0.1663 T0.2314,0.1534 T0.2458,0.141 T0.2561,0.1322 Q0.2815,0.1106,0.3017,0.101 T0.344,0.0926 T0.3919,0.1077 Q0.4099,0.1175,0.4265,0.1206 T0.4588,0.1202 T0.488,0.1098 T0.5176,0.0914 Q0.5071,0.1032,0.4963,0.1122 T0.4722,0.1285 T0.4497,0.1401 T0.4223,0.1518 T0.3943,0.1636 Q0.374,0.1726,0.3623,0.1863 T0.3482,0.2162 Q0.3445,0.2403,0.3608,0.2605 T0.4072,0.2876 Q0.4194,0.2909,0.4326,0.2899 T0.457,0.2856 T0.4805,0.2764 T0.5032,0.264 T0.5256,0.2497 T0.5478,0.2352 T0.5698,0.2222 T0.5921,0.2118 Q0.6982,0.173,0.81,0.2436 Q0.833,0.2583,0.8496,0.2674 T0.887,0.2846 T0.925,0.294 T0.9617,0.2911 T1,0.2746 Q0.9817,0.2946,0.96,0.3056 T0.9175,0.3191 T0.8731,0.3209 T0.8277,0.3164 T0.7818,0.3109 T0.7359,0.3101 T0.6907,0.3197 T0.647,0.3448 Q0.6443,0.3472,0.6333,0.3562 T0.6194,0.3674 T0.6076,0.3766 T0.594,0.386 T0.5815,0.3931 T0.5661,0.4007 T0.5501,0.4064 L0.5556,0.4121 Q0.5627,0.4198,0.5722,0.4251 T0.5926,0.4337 T0.6131,0.439 T0.6343,0.4431 T0.6521,0.4468 Q0.6762,0.4529,0.7056,0.4463 Q0.6772,0.4623,0.6543,0.471 T0.604,0.4814 Q0.5962,0.4818,0.5815,0.4825 T0.5591,0.4835 T0.5393,0.4857 T0.519,0.4908 T0.5007,0.4996 T0.4812,0.5141 T0.4627,0.5353 L0.4549,0.5451 L0.4661,0.543 Q0.4759,0.541,0.4909,0.55 T0.5315,0.5796 T0.5684,0.6083 Q0.5799,0.6161,0.5915,0.6212 T0.6181,0.6308 L0.6331,0.6353 Q0.6514,0.6418,0.7004,0.633 T0.8069,0.6075 Z",type:0}},"\uE0C1":{type:5,data:{d:"M0.011,0.0469 Q0.0152,0.0412,0.02,0.0361 T0.0292,0.0281 T0.0365,0.0253 Q0.0426,0.0257,0.0461,0.0263 T0.0534,0.03 T0.0582,0.0345 T0.0645,0.0434 T0.0723,0.0546 Q0.0807,0.0653,0.0897,0.0712 T0.1081,0.0775 T0.1255,0.0765 T0.1438,0.0695 T0.1605,0.0593 T0.1772,0.0473 Q0.2388,0,0.2927,0.0326 L0.2614,0.0767 L0.2581,0.0767 Q0.2494,0.0767,0.2444,0.0773 T0.2359,0.0789 T0.2291,0.0842 T0.2233,0.0922 T0.2146,0.1054 T0.2026,0.1236 Q0.1943,0.1354,0.1843,0.1444 T0.1675,0.1574 T0.1526,0.1654 T0.1429,0.1701 Q0.1436,0.1717,0.1446,0.1733 Q0.1504,0.1823,0.1581,0.1866 T0.1733,0.1915 T0.1909,0.1884 T0.2085,0.1809 T0.2265,0.169 T0.2427,0.1564 T0.2575,0.1436 T0.2688,0.1338 Q0.2969,0.1101,0.3195,0.0995 T0.3643,0.0901 T0.4114,0.1048 Q0.4279,0.1138,0.4426,0.1168 T0.4705,0.117 T0.4945,0.1089 T0.5186,0.0942 L0.5211,0.0926 L0.5289,0.0922 Q0.5411,0.095,0.5189,0.1203 Q0.505,0.1358,0.4913,0.1474 T0.4634,0.167 T0.4389,0.1796 T0.4121,0.1909 T0.3866,0.2011 Q0.3782,0.2047,0.3727,0.2092 Q0.3717,0.2117,0.3714,0.2141 Q0.3695,0.2263,0.3751,0.2388 T0.394,0.2616 T0.4253,0.2761 Q0.4398,0.2785,0.4513,0.2779 T0.4756,0.2724 T0.4987,0.2616 T0.5216,0.2476 T0.5453,0.2321 T0.5703,0.217 T0.5976,0.2043 Q0.7015,0.166,0.8099,0.2345 Q0.8361,0.2512,0.8572,0.2616 T0.9006,0.2779 T0.9432,0.281 T0.9819,0.2663 L0.9887,0.2671 Q1,0.272,0.9768,0.2969 Q0.9558,0.3193,0.9319,0.333 T0.8858,0.3513 T0.8391,0.3569 T0.793,0.3546 T0.7485,0.3497 T0.7065,0.3473 T0.6681,0.3522 T0.6344,0.3699 Q0.5963,0.4005,0.5847,0.4082 Q0.595,0.4139,0.6079,0.4174 T0.6367,0.4237 T0.6586,0.4278 Q0.6793,0.4331,0.7044,0.4278 Q0.7057,0.4278,0.707,0.4274 Q0.7067,0.429,0.7062,0.4313 T0.7036,0.4398 T0.6993,0.4513 T0.6931,0.4619 T0.6851,0.4698 Q0.6573,0.4857,0.6336,0.4947 T0.5831,0.5053 Q0.577,0.5057,0.5615,0.5065 T0.5394,0.508 T0.5221,0.51 T0.5042,0.5139 T0.4905,0.5204 Q0.5008,0.5232,0.515,0.5328 T0.549,0.5585 T0.5799,0.5824 Q0.5905,0.5897,0.6013,0.5946 T0.6262,0.6036 T0.6409,0.6081 Q0.6567,0.6134,0.7035,0.605 T0.8028,0.5816 L0.7974,0.5922 T0.7867,0.6134 L0.7812,0.624 Q0.7799,0.6248,0.7786,0.6252 Q0.7709,0.6297,0.7573,0.6372 T0.7346,0.6499 T0.7123,0.6621 T0.6897,0.6741 T0.6686,0.6847 T0.648,0.6941 T0.6297,0.7013 T0.6129,0.7062 T0.5995,0.7076 Q0.5992,0.7076,0.5831,0.7092 T0.5529,0.7092 T0.525,0.7019 Q0.5105,0.6958,0.4824,0.6811 T0.4356,0.6586 T0.4079,0.6533 Q0.3872,0.6741,0.3827,0.6782 Q0.3872,0.6835,0.3934,0.6929 T0.4037,0.7078 T0.4142,0.7198 T0.4284,0.7304 T0.4466,0.7365 Q0.4824,0.7443,0.5292,0.728 L0.5292,0.7294 T0.529,0.7333 T0.5284,0.7392 T0.5266,0.7463 T0.5236,0.7539 T0.5186,0.7612 T0.5111,0.7679 Q0.5086,0.77,0.5056,0.7716 Q0.4466,0.8104,0.3591,0.8091 Q0.344,0.8091,0.3246,0.802 T0.2893,0.7863 T0.2543,0.77 T0.2206,0.76 T0.1894,0.7643 Q0.1804,0.7684,0.1733,0.7753 Q0.1591,0.7892,0.1601,0.8169 Q0.1784,0.8067,0.1938,0.8053 T0.2212,0.8087 T0.2507,0.825 Q0.2614,0.8324,0.2744,0.8365 T0.3053,0.844 T0.332,0.8499 L0.303,0.894 L0.2998,0.8948 Q0.2904,0.8968,0.278,0.9009 T0.2554,0.9086 T0.233,0.9158 T0.2099,0.9213 T0.1868,0.9229 T0.1633,0.9192 Q0.1517,0.916,0.1367,0.9241 T0.1065,0.9462 T0.0753,0.9737 T0.041,0.9955 T0.0061,1 Q0,0.9984,0.0068,0.9845 Q0.009,0.9804,0.0123,0.9755 Q0.0187,0.9666,0.0261,0.9606 T0.0374,0.9555 Q0.0481,0.958,0.0629,0.949 T0.0931,0.9262 T0.1247,0.8987 T0.1597,0.8777 T0.1949,0.8752 Q0.213,0.8805,0.2343,0.8772 Q0.2249,0.8736,0.2172,0.8683 Q0.1914,0.8511,0.1738,0.8497 T0.1349,0.8617 Q0.1304,0.865,0.1273,0.8646 T0.1242,0.8605 L0.1239,0.8536 Q0.1226,0.8295,0.1354,0.8016 T0.1723,0.7496 Q0.1914,0.7308,0.2104,0.7219 Q0.2259,0.7145,0.2425,0.7141 T0.273,0.7182 T0.3022,0.7296 T0.3301,0.7439 T0.3569,0.7571 T0.3821,0.7643 Q0.373,0.7561,0.3598,0.7367 T0.3382,0.7088 L0.3327,0.7035 Q0.3282,0.699,0.3353,0.6884 T0.3517,0.6692 L0.3611,0.6607 Q0.3711,0.657,0.3751,0.6554 T0.3837,0.6507 T0.3898,0.6458 T0.3979,0.637 T0.4111,0.6236 Q0.4172,0.6175,0.4234,0.6138 Q0.4327,0.6081,0.4439,0.6077 T0.4655,0.6105 T0.4884,0.6201 T0.5113,0.633 T0.5347,0.6468 T0.5573,0.6578 Q0.5705,0.6635,0.5833,0.665 T0.6112,0.665 T0.6286,0.6635 Q0.6421,0.6631,0.6718,0.6501 Q0.626,0.6578,0.6089,0.6517 Q0.6063,0.6509,0.6002,0.6491 T0.5884,0.6454 T0.5757,0.6409 T0.561,0.6344 T0.5466,0.6256 Q0.536,0.6183,0.5108,0.5989 T0.4722,0.5712 T0.4527,0.564 L0.4418,0.5661 Q0.4308,0.5681,0.4366,0.5563 Q0.4401,0.5485,0.4476,0.5392 L0.455,0.5298 Q0.4631,0.5188,0.4714,0.51 T0.4866,0.4947 T0.5021,0.4831 T0.516,0.4749 T0.53,0.4694 T0.5428,0.4657 T0.5558,0.4637 T0.5674,0.4629 T0.5795,0.4625 Q0.5489,0.4543,0.534,0.4384 L0.5286,0.4327 Q0.5244,0.4282,0.5318,0.4174 T0.5486,0.398 L0.5579,0.3899 Q0.567,0.387,0.5749,0.3834 T0.5878,0.3772 T0.6012,0.3687 T0.6126,0.3603 T0.6268,0.3487 T0.6421,0.3361 Q0.6567,0.3242,0.6712,0.3159 T0.7009,0.3034 T0.7277,0.2973 T0.7556,0.2961 T0.7809,0.2977 T0.8074,0.301 Q0.8096,0.3014,0.8109,0.3016 T0.8143,0.302 T0.818,0.3026 Q0.8012,0.2936,0.7767,0.2781 Q0.6718,0.2117,0.5744,0.2476 Q0.5637,0.2516,0.5492,0.2604 T0.5218,0.2779 T0.4923,0.2965 T0.461,0.3124 T0.4282,0.3216 T0.394,0.3206 Q0.3637,0.3136,0.3474,0.2936 T0.3353,0.2459 Q0.3385,0.2247,0.355,0.2023 Q0.3646,0.1892,0.3763,0.179 Q0.3898,0.1672,0.4063,0.1595 Q0.3927,0.1562,0.3785,0.1485 Q0.3604,0.1387,0.3453,0.1354 T0.3161,0.1354 T0.2899,0.1458 T0.2627,0.166 Q0.262,0.1664,0.254,0.1733 T0.2414,0.1841 T0.2267,0.196 T0.2094,0.2086 T0.1915,0.2196 T0.1725,0.2288 T0.1546,0.2337 T0.1373,0.2343 T0.1223,0.228 T0.1097,0.2145 Q0.0962,0.1929,0.1258,0.1582 Q0.131,0.1517,0.1371,0.1464 Q0.1462,0.1387,0.156,0.1321 T0.1752,0.1205 T0.1878,0.1134 Q0.1891,0.1117,0.1947,0.103 T0.2035,0.0899 T0.2125,0.0781 T0.2243,0.064 Q0.1949,0.0616,0.1672,0.0828 Q0.153,0.0934,0.1412,0.1009 T0.1146,0.1146 T0.0873,0.1213 T0.0616,0.116 T0.0374,0.0962 Q0.0339,0.0909,0.0295,0.0846 T0.024,0.0767 T0.0202,0.0728 T0.0148,0.0704 T0.0068,0.0697 Q0.0003,0.0693,0.0058,0.0567 Q0.0077,0.0522,0.011,0.0469 Z",type:0}},"\uE0C2":{type:5,data:{d:"M0.1934,0.6075 Q0.2012,0.612,0.2188,0.6218 T0.2459,0.6369 T0.2718,0.6508 T0.2983,0.6644 T0.322,0.6758 T0.3447,0.6854 T0.363,0.6911 T0.3783,0.6936 Q0.3796,0.6936,0.396,0.6952 T0.4265,0.6952 T0.4548,0.6879 Q0.4656,0.6834,0.4805,0.6756 T0.5073,0.6614 T0.5327,0.6485 T0.5564,0.6389 T0.5759,0.6363 T0.5909,0.6422 Q0.5953,0.6467,0.604,0.6554 T0.6155,0.6669 T0.6251,0.6746 T0.6387,0.683 T0.6556,0.6903 L0.6498,0.6956 Q0.6414,0.7042,0.6319,0.7177 T0.6162,0.7403 T0.5979,0.7583 T0.5703,0.7703 Q0.5313,0.7785,0.4799,0.7605 Q0.5097,0.7817,0.5508,0.7911 T0.6302,0.8001 Q0.6431,0.7997,0.659,0.7942 T0.6888,0.7813 T0.7194,0.766 T0.7506,0.753 T0.7819,0.7472 T0.8131,0.754 Q0.8239,0.7589,0.8346,0.7681 T0.8544,0.7895 T0.8689,0.818 T0.8737,0.8507 L0.8733,0.858 Q0.8591,0.849,0.8474,0.845 T0.8242,0.8421 T0.8021,0.8482 T0.7772,0.8625 Q0.7653,0.8707,0.7513,0.8752 T0.7184,0.8831 T0.6908,0.889 Q0.7017,0.8915,0.7152,0.8956 T0.7396,0.9037 T0.7633,0.9115 T0.7872,0.9174 T0.8107,0.9194 T0.8347,0.9155 Q0.8493,0.9115,0.8671,0.9196 T0.9015,0.9421 T0.9345,0.9708 T0.9682,0.9943 T0.9997,1 Q0.999,0.94,0.9983,0.6508 T0.9976,0.2322 Q0.9976,0.2109,0.9992,0.1379 T1,0.0249 Q0.9949,0.0249,0.9917,0.0257 T0.9863,0.0273 T0.9817,0.0308 T0.9776,0.0357 T0.9721,0.0437 T0.9651,0.0539 Q0.956,0.0657,0.9456,0.072 T0.9247,0.0789 T0.904,0.0775 T0.8823,0.0694 T0.862,0.0573 T0.8422,0.0432 Q0.7856,0,0.7328,0.0318 Q0.7392,0.0318,0.7452,0.0324 T0.756,0.0345 T0.7657,0.0386 T0.7741,0.0437 T0.7821,0.0506 T0.7894,0.0588 T0.7966,0.0683 T0.8039,0.0787 T0.8119,0.0904 T0.8202,0.1028 Q0.8249,0.1093,0.849,0.1234 T0.8811,0.1448 Q0.8998,0.1612,0.8893,0.1783 Q0.8835,0.1877,0.8759,0.1928 T0.8593,0.1979 T0.8417,0.1967 T0.8222,0.1895 T0.8034,0.1791 T0.7848,0.1663 T0.7687,0.1534 T0.7543,0.141 T0.744,0.1322 Q0.7186,0.1106,0.6984,0.101 T0.6559,0.0926 T0.6082,0.1077 Q0.5899,0.1175,0.5733,0.1206 T0.5411,0.1202 T0.5122,0.1098 T0.4822,0.0914 Q0.4927,0.1032,0.5036,0.1122 T0.5276,0.1285 T0.5503,0.1401 T0.5777,0.1518 T0.6055,0.1636 Q0.6258,0.1726,0.6375,0.1863 T0.6519,0.2162 Q0.6556,0.2403,0.6392,0.2605 T0.5926,0.2876 Q0.5804,0.2909,0.5672,0.2899 T0.5428,0.2856 T0.5193,0.2764 T0.4966,0.264 T0.4744,0.2497 T0.4523,0.2352 T0.4301,0.2222 T0.4077,0.2118 Q0.3021,0.173,0.1903,0.2436 Q0.1669,0.2583,0.1504,0.2674 T0.1129,0.2846 T0.075,0.294 T0.0384,0.2911 T0,0.2746 Q0.021,0.297,0.0459,0.3082 T0.0953,0.3207 T0.146,0.3193 T0.1988,0.3131 T0.2508,0.3095 T0.3029,0.3176 T0.3529,0.3448 Q0.3566,0.348,0.3652,0.355 T0.3771,0.3645 T0.3877,0.3731 T0.3993,0.3815 T0.4103,0.3884 T0.4226,0.3953 T0.4352,0.4009 T0.4497,0.4064 L0.4443,0.4121 Q0.4372,0.4198,0.4277,0.4251 T0.4072,0.4337 T0.3867,0.439 T0.3656,0.4431 T0.3481,0.4468 Q0.3241,0.4529,0.2943,0.4463 Q0.3227,0.4623,0.3458,0.471 T0.3959,0.4814 Q0.404,0.4818,0.4187,0.4825 T0.4409,0.4835 T0.4605,0.4857 T0.4809,0.4908 T0.4992,0.4996 T0.5186,0.5141 T0.5371,0.5353 L0.5449,0.5451 L0.5337,0.543 Q0.5242,0.541,0.5091,0.55 T0.4683,0.5796 T0.4314,0.6083 Q0.4203,0.6161,0.4086,0.6212 T0.382,0.6308 T0.3667,0.6353 Q0.3485,0.6418,0.2995,0.633 T0.1934,0.6075 Z",type:0}},"\uE0C3":{type:5,data:{d:"M0.989,0.0469 Q0.9829,0.0379,0.9753,0.0316 T0.9635,0.0253 Q0.9574,0.0257,0.9539,0.0263 T0.9466,0.03 T0.9418,0.0345 T0.9355,0.0434 T0.9277,0.0546 Q0.9193,0.0653,0.9103,0.0712 T0.8919,0.0775 T0.8745,0.0765 T0.8564,0.0695 T0.8396,0.0593 T0.8228,0.0473 Q0.7612,0,0.7073,0.0326 L0.7386,0.0767 L0.7419,0.0767 Q0.7506,0.0767,0.7556,0.0773 T0.7641,0.0789 T0.7709,0.0842 T0.7767,0.0922 T0.7854,0.1054 T0.7974,0.1236 Q0.8057,0.1354,0.8157,0.1444 T0.8325,0.1574 T0.8474,0.1654 T0.8571,0.1701 Q0.8564,0.1717,0.8554,0.1733 Q0.8503,0.1815,0.844,0.186 T0.8301,0.1909 T0.8153,0.1903 T0.7993,0.1845 T0.7836,0.1758 T0.768,0.1648 T0.754,0.1535 T0.741,0.1425 T0.7312,0.1338 Q0.7031,0.1101,0.6805,0.0995 T0.6357,0.0901 T0.5886,0.1048 Q0.5721,0.1138,0.5574,0.1168 T0.5295,0.117 T0.5055,0.1089 T0.4814,0.0942 L0.4789,0.0926 L0.4711,0.0922 Q0.4589,0.095,0.4811,0.1203 Q0.495,0.1358,0.5087,0.1474 T0.5366,0.167 T0.5611,0.1796 T0.5879,0.1909 T0.6134,0.2011 Q0.6218,0.2047,0.6273,0.2092 Q0.6283,0.2117,0.6286,0.2141 Q0.6305,0.2263,0.6249,0.2388 T0.606,0.2616 T0.5747,0.2761 Q0.5602,0.2785,0.5487,0.2779 T0.5245,0.2724 T0.5015,0.2616 T0.4784,0.2476 T0.4547,0.2321 T0.4297,0.217 T0.4024,0.2043 Q0.2985,0.166,0.1901,0.2345 Q0.1694,0.248,0.1523,0.2569 T0.1165,0.2726 T0.0818,0.2812 T0.0494,0.2796 T0.0181,0.2663 L0.0116,0.2671 Q0,0.272,0.0232,0.2969 Q0.0468,0.3222,0.0742,0.3365 T0.1278,0.3542 T0.1807,0.3564 T0.233,0.352 T0.2815,0.3473 T0.3269,0.3509 T0.3656,0.3699 Q0.4037,0.4005,0.4153,0.4082 Q0.405,0.4139,0.3921,0.4174 T0.3633,0.4237 T0.3414,0.4278 Q0.3207,0.4331,0.2956,0.4278 Q0.2943,0.4278,0.293,0.4274 Q0.2933,0.429,0.2938,0.4313 T0.2964,0.4398 T0.3007,0.4513 T0.3069,0.4619 T0.3149,0.4698 Q0.3427,0.4857,0.3664,0.4947 T0.4169,0.5053 Q0.423,0.5057,0.4385,0.5065 T0.4606,0.508 T0.4779,0.51 T0.4958,0.5139 T0.5095,0.5204 Q0.4992,0.5232,0.485,0.5328 T0.451,0.5585 T0.4201,0.5824 Q0.4095,0.5897,0.3987,0.5946 T0.3738,0.6036 T0.3591,0.6081 Q0.3433,0.6134,0.2965,0.605 T0.1972,0.5816 L0.2026,0.5922 T0.2133,0.6134 L0.2188,0.624 Q0.2201,0.6248,0.2214,0.6252 Q0.2291,0.6297,0.2486,0.6405 T0.2786,0.657 T0.3066,0.6721 T0.3353,0.6866 T0.3598,0.6972 T0.383,0.7051 T0.4005,0.7076 Q0.4008,0.7076,0.4169,0.7092 T0.4471,0.7092 T0.475,0.7019 Q0.4895,0.6958,0.5176,0.6811 T0.5644,0.6586 T0.5921,0.6533 Q0.6128,0.6741,0.6173,0.6782 Q0.6131,0.6835,0.6068,0.6929 T0.5963,0.7078 T0.5858,0.7198 T0.5716,0.7304 T0.5534,0.7365 Q0.5179,0.7443,0.4708,0.728 L0.4708,0.7294 T0.471,0.7333 T0.4716,0.7392 T0.4734,0.7463 T0.4764,0.7539 T0.4814,0.7612 T0.4889,0.7679 Q0.4914,0.77,0.4944,0.7716 Q0.5534,0.8104,0.6409,0.8091 Q0.6521,0.8091,0.6654,0.8053 T0.6907,0.7959 T0.7157,0.7838 T0.741,0.772 T0.7652,0.7631 T0.7888,0.7594 T0.8106,0.7643 Q0.8196,0.7684,0.8267,0.7753 Q0.8409,0.7892,0.8399,0.8169 Q0.8216,0.8067,0.8062,0.8053 T0.7788,0.8087 T0.7493,0.825 Q0.7386,0.8324,0.7256,0.8365 T0.6947,0.844 T0.668,0.8499 L0.697,0.894 L0.7002,0.8948 Q0.7099,0.8968,0.7222,0.9009 T0.7446,0.9086 T0.767,0.9158 T0.7901,0.9213 T0.8132,0.9229 T0.8367,0.9192 Q0.8483,0.916,0.8633,0.9241 T0.8937,0.9462 T0.9248,0.9737 T0.959,0.9955 T0.9939,1 Q1,0.9984,0.9932,0.9845 Q0.991,0.9804,0.9877,0.9755 Q0.9813,0.9666,0.9739,0.9606 T0.9626,0.9555 Q0.9519,0.958,0.9372,0.949 T0.9071,0.9262 T0.8753,0.8987 T0.8403,0.8777 T0.8051,0.8752 Q0.787,0.8805,0.7657,0.8772 Q0.7751,0.8736,0.7828,0.8683 Q0.8086,0.8511,0.8262,0.8497 T0.8651,0.8617 Q0.8696,0.865,0.8727,0.8646 T0.8758,0.8605 L0.8761,0.8536 Q0.8774,0.8295,0.8646,0.8016 T0.8277,0.7496 Q0.8086,0.7308,0.7896,0.7219 Q0.7741,0.7145,0.7575,0.7141 T0.727,0.7182 T0.6978,0.7296 T0.6699,0.7439 T0.6433,0.7571 T0.6179,0.7643 Q0.627,0.7561,0.6402,0.7367 T0.6618,0.7088 L0.6673,0.7035 Q0.6718,0.699,0.6647,0.6884 T0.6483,0.6692 L0.6389,0.6607 Q0.6289,0.657,0.6249,0.6554 T0.6163,0.6507 T0.6102,0.6458 T0.6021,0.637 T0.5889,0.6236 Q0.5828,0.6175,0.5766,0.6138 Q0.5673,0.6081,0.5561,0.6077 T0.5345,0.6105 T0.5116,0.6201 T0.4887,0.633 T0.4653,0.6468 T0.4427,0.6578 Q0.4295,0.6635,0.4167,0.665 T0.3888,0.665 T0.3714,0.6635 Q0.3579,0.6631,0.3282,0.6501 Q0.374,0.6578,0.3911,0.6517 Q0.3937,0.6509,0.4024,0.6482 T0.4179,0.6434 T0.4348,0.6364 T0.4534,0.6256 Q0.464,0.6183,0.4892,0.5989 T0.5278,0.5712 T0.5476,0.564 L0.5582,0.5661 Q0.5692,0.5681,0.5634,0.5563 Q0.5599,0.5485,0.5524,0.5392 L0.545,0.5298 Q0.5369,0.5188,0.5286,0.51 T0.5134,0.4947 T0.4979,0.4831 T0.484,0.4749 T0.47,0.4694 T0.4572,0.4657 T0.4442,0.4637 T0.4326,0.4629 T0.4205,0.4625 Q0.4511,0.4543,0.466,0.4384 L0.4714,0.4327 Q0.4795,0.4237,0.4469,0.394 L0.4421,0.3899 Q0.433,0.387,0.4251,0.3834 T0.4122,0.3772 T0.3988,0.3687 T0.3874,0.3603 T0.3732,0.3487 T0.3579,0.3361 Q0.3433,0.3242,0.3288,0.3159 T0.2993,0.3034 T0.2725,0.2973 T0.2446,0.2961 T0.2193,0.2977 T0.1926,0.301 Q0.1855,0.3022,0.182,0.3026 Q0.1991,0.2936,0.2233,0.2781 Q0.3282,0.2117,0.4256,0.2476 Q0.4363,0.2516,0.451,0.2604 T0.4784,0.2779 T0.5077,0.2965 T0.539,0.3124 T0.5718,0.3216 T0.606,0.3206 Q0.6363,0.3136,0.6526,0.2936 T0.6647,0.2459 Q0.6615,0.2247,0.645,0.2023 Q0.6354,0.1892,0.6237,0.179 Q0.6102,0.1672,0.5937,0.1595 Q0.6073,0.1562,0.6215,0.1485 Q0.6396,0.1387,0.6547,0.1354 T0.6839,0.1354 T0.7101,0.1458 T0.7373,0.166 Q0.738,0.1664,0.746,0.1733 T0.7586,0.1841 T0.7733,0.196 T0.7907,0.2086 T0.8086,0.2196 T0.8275,0.2288 T0.8454,0.2337 T0.8627,0.2343 T0.8777,0.228 T0.8903,0.2145 Q0.9038,0.1929,0.8742,0.1582 Q0.869,0.1517,0.8629,0.1464 Q0.8538,0.1387,0.844,0.1321 T0.8248,0.1205 T0.8122,0.1134 Q0.8109,0.1117,0.8053,0.103 T0.7965,0.0899 T0.7875,0.0781 T0.7757,0.064 Q0.8051,0.0616,0.8328,0.0828 Q0.847,0.0934,0.8588,0.1009 T0.8854,0.1146 T0.9127,0.1213 T0.9384,0.116 T0.9626,0.0962 Q0.9664,0.0909,0.9706,0.0846 T0.976,0.0767 T0.9798,0.0728 T0.9852,0.0704 T0.9932,0.0697 Q0.9997,0.0693,0.9942,0.0567 Q0.9923,0.0522,0.989,0.0469 Z",type:0}},"\uE0C4":{type:5,data:{d:"M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.4116,0.0126 L0.4116,0.0776 L0.4871,0.0776 L0.4871,0.0126 L0.4116,0.0126 Z M0.4043,0 L0.4944,0 Q0.4973,0,0.4995,0.0021 T0.5017,0.0063 L0.5017,0.0839 Q0.5017,0.0864,0.4993,0.0883 T0.4944,0.0902 L0.4043,0.0902 Q0.4014,0.0902,0.3992,0.0881 T0.397,0.0839 L0.397,0.0063 Q0.397,0.0038,0.3994,0.0019 T0.4043,0 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.9099,0.0084 L0.9099,0.0818 L0.9951,0.0818 L0.9951,0.0084 L0.9099,0.0084 Z M0.9075,0.0042 L0.9976,0.0042 Q1,0.0042,1,0.0063 L1,0.0839 Q1,0.086,0.9976,0.086 L0.9075,0.086 Q0.905,0.086,0.905,0.0839 L0.905,0.0063 Q0.905,0.0042,0.9075,0.0042 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.66,0.0063 L0.7501,0.0063 L0.7501,0.0839 L0.66,0.0839 L0.66,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.5309,0.0063 L0.621,0.0063 L0.621,0.0839 L0.5309,0.0839 L0.5309,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.2689,0.0063 L0.359,0.0063 L0.359,0.0839 L0.2689,0.0839 L0.2689,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0.1374,0.0063 L0.2275,0.0063 L0.2275,0.0839 L0.1374,0.0839 L0.1374,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0,0.0063 L0.0901,0.0063 L0.0901,0.0839 L0,0.0839 L0,0.0063 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.4087,0.9224 L0.4087,0.9958 L0.4939,0.9958 L0.4939,0.9224 L0.4087,0.9224 Z M0.4062,0.9182 L0.4963,0.9182 Q0.4988,0.9182,0.4988,0.9203 L0.4988,0.9979 Q0.4988,1,0.4963,1 L0.4062,1 Q0.4038,1,0.4038,0.9979 L0.4038,0.9203 Q0.4038,0.9182,0.4062,0.9182 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0.1374,0.9203 L0.2275,0.9203 L0.2275,0.9979 L0.1374,0.9979 L0.1374,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0,0.9203 L0.0901,0.9203 L0.0901,0.9979 L0,0.9979 L0,0.9203 Z M0.9099,0.1158 L0.9099,0.1888 L0.9951,0.1888 L0.9951,0.1158 L0.9099,0.1158 Z M0.9075,0.1116 L0.9976,0.1116 Q1,0.1116,1,0.1137 L1,0.1909 Q1,0.193,0.9976,0.193 L0.9075,0.193 Q0.905,0.193,0.905,0.1909 L0.905,0.1137 Q0.905,0.1116,0.9075,0.1116 Z M0.7852,0.6086 L0.7852,0.682 L0.8704,0.682 L0.8704,0.6086 L0.7852,0.6086 Z M0.7828,0.6044 L0.8729,0.6044 Q0.8753,0.6044,0.8753,0.6065 L0.8753,0.6841 Q0.8753,0.6862,0.8729,0.6862 L0.7828,0.6862 Q0.7803,0.6862,0.7803,0.6841 L0.7803,0.6065 Q0.7803,0.6044,0.7828,0.6044 Z M0.66,0.1137 L0.7501,0.1137 L0.7501,0.1909 L0.66,0.1909 L0.66,0.1137 Z M0.6624,0.4102 L0.6624,0.4836 L0.7477,0.4836 L0.7477,0.4102 L0.6624,0.4102 Z M0.66,0.406 L0.7501,0.406 Q0.7526,0.406,0.7526,0.4081 L0.7526,0.4857 Q0.7526,0.4878,0.7501,0.4878 L0.66,0.4878 Q0.6576,0.4878,0.6576,0.4857 L0.6576,0.4081 Q0.6576,0.406,0.66,0.406 Z M0.6624,0.7131 L0.6624,0.7865 L0.7477,0.7865 L0.7477,0.7131 L0.6624,0.7131 Z M0.66,0.7089 L0.7501,0.7089 Q0.7526,0.7089,0.7526,0.711 L0.7526,0.7886 Q0.7526,0.7907,0.7501,0.7907 L0.66,0.7907 Q0.6576,0.7907,0.6576,0.7886 L0.6576,0.711 Q0.6576,0.7089,0.66,0.7089 Z M0.5407,0.2227 L0.5407,0.2836 L0.6113,0.2836 L0.6113,0.2227 L0.5407,0.2227 Z M0.5309,0.206 L0.621,0.206 Q0.623,0.206,0.6249,0.2068 T0.6281,0.2089 T0.6301,0.2116 T0.6308,0.2143 L0.6308,0.2919 Q0.6308,0.2957,0.6276,0.298 T0.621,0.3003 L0.5309,0.3003 Q0.5265,0.3003,0.5239,0.2976 T0.5212,0.2919 L0.5212,0.2143 Q0.5212,0.211,0.5244,0.2085 T0.5309,0.206 Z M0.5407,0.3184 L0.5407,0.3792 L0.6113,0.3792 L0.6113,0.3184 L0.5407,0.3184 Z M0.5309,0.3016 L0.621,0.3016 Q0.6254,0.3016,0.6281,0.3043 T0.6308,0.31 L0.6308,0.3876 Q0.6308,0.3914,0.6276,0.3937 T0.621,0.396 L0.5309,0.396 Q0.5265,0.396,0.5239,0.3932 T0.5212,0.3876 L0.5212,0.31 Q0.5212,0.3062,0.5244,0.3039 T0.5309,0.3016 Z M0.5309,0.4081 L0.621,0.4081 L0.621,0.4857 L0.5309,0.4857 L0.5309,0.4081 Z M0.5309,0.5059 L0.621,0.5059 L0.621,0.5835 L0.5309,0.5835 L0.5309,0.5059 Z M0.5407,0.6149 L0.5407,0.6758 L0.6113,0.6758 L0.6113,0.6149 L0.5407,0.6149 Z M0.5309,0.5982 L0.621,0.5982 Q0.6254,0.5982,0.6281,0.6009 T0.6308,0.6065 L0.6308,0.6841 Q0.6308,0.6879,0.6276,0.6902 T0.621,0.6925 L0.5309,0.6925 Q0.5265,0.6925,0.5239,0.6898 T0.5212,0.6841 L0.5212,0.6065 Q0.5212,0.6028,0.5244,0.6005 T0.5309,0.5982 Z M0.4062,0.1137 L0.4963,0.1137 L0.4963,0.1909 L0.4062,0.1909 L0.4062,0.1137 Z M0.4062,0.2143 L0.4963,0.2143 L0.4963,0.2919 L0.4062,0.2919 L0.4062,0.2143 Z M0.4184,0.4186 L0.4184,0.4753 L0.4842,0.4753 L0.4842,0.4186 L0.4184,0.4186 Z M0.4062,0.3977 L0.4963,0.3977 Q0.5017,0.3977,0.5051,0.401 T0.5085,0.4081 L0.5085,0.4857 Q0.5085,0.4904,0.5046,0.4933 T0.4963,0.4962 L0.4062,0.4962 Q0.4009,0.4962,0.3975,0.4929 T0.3941,0.4857 L0.3941,0.4081 Q0.3941,0.4035,0.398,0.4006 T0.4062,0.3977 Z M0.4062,0.5059 L0.4963,0.5059 L0.4963,0.5835 L0.4062,0.5835 L0.4062,0.5059 Z M0.4087,0.7131 L0.4087,0.7865 L0.4939,0.7865 L0.4939,0.7131 L0.4087,0.7131 Z M0.4062,0.7089 L0.4963,0.7089 Q0.4988,0.7089,0.4988,0.711 L0.4988,0.7886 Q0.4988,0.7907,0.4963,0.7907 L0.4062,0.7907 Q0.4038,0.7907,0.4038,0.7886 L0.4038,0.711 Q0.4038,0.7089,0.4062,0.7089 Z M0.2689,0.1137 L0.359,0.1137 L0.359,0.1909 L0.2689,0.1909 L0.2689,0.1137 Z M0.2689,0.2143 L0.359,0.2143 L0.359,0.2919 L0.2689,0.2919 L0.2689,0.2143 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.31 L0.359,0.31 L0.359,0.3876 L0.2689,0.3876 L0.2689,0.31 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.2689,0.4081 L0.359,0.4081 L0.359,0.4857 L0.2689,0.4857 L0.2689,0.4081 Z M0.2689,0.5059 L0.359,0.5059 L0.359,0.5835 L0.2689,0.5835 L0.2689,0.5059 Z M0.2689,0.711 L0.359,0.711 L0.359,0.7886 L0.2689,0.7886 L0.2689,0.711 Z M0.2689,0.8154 L0.359,0.8154 L0.359,0.8926 L0.2689,0.8926 L0.2689,0.8154 Z M0.1374,0.1137 L0.2275,0.1137 L0.2275,0.1909 L0.1374,0.1909 L0.1374,0.1137 Z M0.1374,0.2143 L0.2275,0.2143 L0.2275,0.2919 L0.1374,0.2919 L0.1374,0.2143 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.31 L0.2275,0.31 L0.2275,0.3876 L0.1374,0.3876 L0.1374,0.31 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0.1374,0.4081 L0.2275,0.4081 L0.2275,0.4857 L0.1374,0.4857 L0.1374,0.4081 Z M0.1374,0.5059 L0.2275,0.5059 L0.2275,0.5835 L0.1374,0.5835 L0.1374,0.5059 Z M0.1374,0.6065 L0.2275,0.6065 L0.2275,0.6841 L0.1374,0.6841 L0.1374,0.6065 Z M0.1374,0.711 L0.2275,0.711 L0.2275,0.7886 L0.1374,0.7886 L0.1374,0.711 Z M0.1374,0.8154 L0.2275,0.8154 L0.2275,0.8926 L0.1374,0.8926 L0.1374,0.8154 Z M0,0.1137 L0.0901,0.1137 L0.0901,0.1909 L0,0.1909 L0,0.1137 Z M0,0.2143 L0.0901,0.2143 L0.0901,0.2919 L0,0.2919 L0,0.2143 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.31 L0.0901,0.31 L0.0901,0.3876 L0,0.3876 L0,0.31 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z M0,0.4081 L0.0901,0.4081 L0.0901,0.4857 L0,0.4857 L0,0.4081 Z M0,0.5059 L0.0901,0.5059 L0.0901,0.5835 L0,0.5835 L0,0.5059 Z M0,0.6065 L0.0901,0.6065 L0.0901,0.6841 L0,0.6841 L0,0.6065 Z M0,0.711 L0.0901,0.711 L0.0901,0.7886 L0,0.7886 L0,0.711 Z M0,0.8154 L0.0901,0.8154 L0.0901,0.8926 L0,0.8926 L0,0.8154 Z",type:0}},"\uE0C5":{type:5,data:{d:"M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.5884,0.0126 L0.5884,0.0776 L0.5129,0.0776 L0.5129,0.0126 L0.5884,0.0126 Z M0.5957,0 L0.5056,0 Q0.5027,0,0.5005,0.0021 T0.4983,0.0063 L0.4983,0.0839 Q0.4983,0.0864,0.5007,0.0883 T0.5056,0.0902 L0.5957,0.0902 Q0.5986,0.0902,0.6008,0.0881 T0.603,0.0839 L0.603,0.0063 Q0.603,0.0038,0.6006,0.0019 T0.5957,0 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.0901,0.0084 L0.0901,0.0818 L0.0049,0.0818 L0.0049,0.0084 L0.0901,0.0084 Z M0.0925,0.0042 L0.0024,0.0042 Q0,0.0042,0,0.0063 L0,0.0839 Q0,0.086,0.0024,0.086 L0.0925,0.086 Q0.095,0.086,0.095,0.0839 L0.095,0.0063 Q0.095,0.0042,0.0925,0.0042 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.34,0.0063 L0.2499,0.0063 L0.2499,0.0839 L0.34,0.0839 L0.34,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.4691,0.0063 L0.379,0.0063 L0.379,0.0839 L0.4691,0.0839 L0.4691,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.7311,0.0063 L0.641,0.0063 L0.641,0.0839 L0.7311,0.0839 L0.7311,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M0.8626,0.0063 L0.7725,0.0063 L0.7725,0.0839 L0.8626,0.0839 L0.8626,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M1,0.0063 L0.9099,0.0063 L0.9099,0.0839 L1,0.0839 L1,0.0063 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.5913,0.9224 L0.5913,0.9958 L0.5061,0.9958 L0.5061,0.9224 L0.5913,0.9224 Z M0.5938,0.9182 L0.5037,0.9182 Q0.5012,0.9182,0.5012,0.9203 L0.5012,0.9979 Q0.5012,1,0.5037,1 L0.5938,1 Q0.5962,1,0.5962,0.9979 L0.5962,0.9203 Q0.5962,0.9182,0.5938,0.9182 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M0.8626,0.9203 L0.7725,0.9203 L0.7725,0.9979 L0.8626,0.9979 L0.8626,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M1,0.9203 L0.9099,0.9203 L0.9099,0.9979 L1,0.9979 L1,0.9203 Z M0.0901,0.1158 L0.0901,0.1888 L0.0049,0.1888 L0.0049,0.1158 L0.0901,0.1158 Z M0.0925,0.1116 L0.0024,0.1116 Q0,0.1116,0,0.1137 L0,0.1909 Q0,0.193,0.0024,0.193 L0.0925,0.193 Q0.095,0.193,0.095,0.1909 L0.095,0.1137 Q0.095,0.1116,0.0925,0.1116 Z M0.2148,0.6086 L0.2148,0.682 L0.1296,0.682 L0.1296,0.6086 L0.2148,0.6086 Z M0.2172,0.6044 L0.1271,0.6044 Q0.1247,0.6044,0.1247,0.6065 L0.1247,0.6841 Q0.1247,0.6862,0.1271,0.6862 L0.2172,0.6862 Q0.2197,0.6862,0.2197,0.6841 L0.2197,0.6065 Q0.2197,0.6044,0.2172,0.6044 Z M0.34,0.1137 L0.2499,0.1137 L0.2499,0.1909 L0.34,0.1909 L0.34,0.1137 Z M0.3376,0.4102 L0.3376,0.4836 L0.2523,0.4836 L0.2523,0.4102 L0.3376,0.4102 Z M0.34,0.406 L0.2499,0.406 Q0.2474,0.406,0.2474,0.4081 L0.2474,0.4857 Q0.2474,0.4878,0.2499,0.4878 L0.34,0.4878 Q0.3424,0.4878,0.3424,0.4857 L0.3424,0.4081 Q0.3424,0.406,0.34,0.406 Z M0.3376,0.7131 L0.3376,0.7865 L0.2523,0.7865 L0.2523,0.7131 L0.3376,0.7131 Z M0.34,0.7089 L0.2499,0.7089 Q0.2474,0.7089,0.2474,0.711 L0.2474,0.7886 Q0.2474,0.7907,0.2499,0.7907 L0.34,0.7907 Q0.3424,0.7907,0.3424,0.7886 L0.3424,0.711 Q0.3424,0.7089,0.34,0.7089 Z M0.4593,0.2227 L0.4593,0.2836 L0.3887,0.2836 L0.3887,0.2227 L0.4593,0.2227 Z M0.4691,0.206 L0.379,0.206 Q0.3746,0.206,0.3719,0.2087 T0.3692,0.2143 L0.3692,0.2919 Q0.3692,0.2957,0.3724,0.298 T0.379,0.3003 L0.4691,0.3003 Q0.4735,0.3003,0.4761,0.2976 T0.4788,0.2919 L0.4788,0.2143 Q0.4788,0.211,0.4756,0.2085 T0.4691,0.206 Z M0.4593,0.3184 L0.4593,0.3792 L0.3887,0.3792 L0.3887,0.3184 L0.4593,0.3184 Z M0.4691,0.3016 L0.379,0.3016 Q0.3746,0.3016,0.3719,0.3043 T0.3692,0.31 L0.3692,0.3876 Q0.3692,0.3914,0.3724,0.3937 T0.379,0.396 L0.4691,0.396 Q0.4735,0.396,0.4761,0.3932 T0.4788,0.3876 L0.4788,0.31 Q0.4788,0.3062,0.4756,0.3039 T0.4691,0.3016 Z M0.4691,0.4081 L0.379,0.4081 L0.379,0.4857 L0.4691,0.4857 L0.4691,0.4081 Z M0.4691,0.5059 L0.379,0.5059 L0.379,0.5835 L0.4691,0.5835 L0.4691,0.5059 Z M0.4593,0.6149 L0.4593,0.6758 L0.3887,0.6758 L0.3887,0.6149 L0.4593,0.6149 Z M0.4691,0.5982 L0.379,0.5982 Q0.3746,0.5982,0.3719,0.6009 T0.3692,0.6065 L0.3692,0.6841 Q0.3692,0.6879,0.3724,0.6902 T0.379,0.6925 L0.4691,0.6925 Q0.4735,0.6925,0.4761,0.6898 T0.4788,0.6841 L0.4788,0.6065 Q0.4788,0.6028,0.4756,0.6005 T0.4691,0.5982 Z M0.5938,0.1137 L0.5037,0.1137 L0.5037,0.1909 L0.5938,0.1909 L0.5938,0.1137 Z M0.5938,0.2143 L0.5037,0.2143 L0.5037,0.2919 L0.5938,0.2919 L0.5938,0.2143 Z M0.5816,0.4186 L0.5816,0.4753 L0.5158,0.4753 L0.5158,0.4186 L0.5816,0.4186 Z M0.5938,0.3977 L0.5037,0.3977 Q0.4983,0.3977,0.4949,0.401 T0.4915,0.4081 L0.4915,0.4857 Q0.4915,0.4904,0.4954,0.4933 T0.5037,0.4962 L0.5938,0.4962 Q0.5991,0.4962,0.6025,0.4929 T0.6059,0.4857 L0.6059,0.4081 Q0.6059,0.4035,0.602,0.4006 T0.5938,0.3977 Z M0.5938,0.5059 L0.5037,0.5059 L0.5037,0.5835 L0.5938,0.5835 L0.5938,0.5059 Z M0.5913,0.7131 L0.5913,0.7865 L0.5061,0.7865 L0.5061,0.7131 L0.5913,0.7131 Z M0.5938,0.7089 L0.5037,0.7089 Q0.5012,0.7089,0.5012,0.711 L0.5012,0.7886 Q0.5012,0.7907,0.5037,0.7907 L0.5938,0.7907 Q0.5962,0.7907,0.5962,0.7886 L0.5962,0.711 Q0.5962,0.7089,0.5938,0.7089 Z M0.7311,0.1137 L0.641,0.1137 L0.641,0.1909 L0.7311,0.1909 L0.7311,0.1137 Z M0.7311,0.2143 L0.641,0.2143 L0.641,0.2919 L0.7311,0.2919 L0.7311,0.2143 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.31 L0.641,0.31 L0.641,0.3876 L0.7311,0.3876 L0.7311,0.31 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.7311,0.4081 L0.641,0.4081 L0.641,0.4857 L0.7311,0.4857 L0.7311,0.4081 Z M0.7311,0.5059 L0.641,0.5059 L0.641,0.5835 L0.7311,0.5835 L0.7311,0.5059 Z M0.7311,0.711 L0.641,0.711 L0.641,0.7886 L0.7311,0.7886 L0.7311,0.711 Z M0.7311,0.8154 L0.641,0.8154 L0.641,0.8926 L0.7311,0.8926 L0.7311,0.8154 Z M0.8626,0.1137 L0.7725,0.1137 L0.7725,0.1909 L0.8626,0.1909 L0.8626,0.1137 Z M0.8626,0.2143 L0.7725,0.2143 L0.7725,0.2919 L0.8626,0.2919 L0.8626,0.2143 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.31 L0.7725,0.31 L0.7725,0.3876 L0.8626,0.3876 L0.8626,0.31 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M0.8626,0.4081 L0.7725,0.4081 L0.7725,0.4857 L0.8626,0.4857 L0.8626,0.4081 Z M0.8626,0.5059 L0.7725,0.5059 L0.7725,0.5835 L0.8626,0.5835 L0.8626,0.5059 Z M0.8626,0.6065 L0.7725,0.6065 L0.7725,0.6841 L0.8626,0.6841 L0.8626,0.6065 Z M0.8626,0.711 L0.7725,0.711 L0.7725,0.7886 L0.8626,0.7886 L0.8626,0.711 Z M0.8626,0.8154 L0.7725,0.8154 L0.7725,0.8926 L0.8626,0.8926 L0.8626,0.8154 Z M1,0.1137 L0.9099,0.1137 L0.9099,0.1909 L1,0.1909 L1,0.1137 Z M1,0.2143 L0.9099,0.2143 L0.9099,0.2919 L1,0.2919 L1,0.2143 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.31 L0.9099,0.31 L0.9099,0.3876 L1,0.3876 L1,0.31 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z M1,0.4081 L0.9099,0.4081 L0.9099,0.4857 L1,0.4857 L1,0.4081 Z M1,0.5059 L0.9099,0.5059 L0.9099,0.5835 L1,0.5835 L1,0.5059 Z M1,0.6065 L0.9099,0.6065 L0.9099,0.6841 L1,0.6841 L1,0.6065 Z M1,0.711 L0.9099,0.711 L0.9099,0.7886 L1,0.7886 L1,0.711 Z M1,0.8154 L0.9099,0.8154 L0.9099,0.8926 L1,0.8926 L1,0.8154 Z",type:0}},"\uE0C6":{type:5,data:{d:"M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.6529,0.018 L0.6529,0.112 L0.7724,0.112 L0.7724,0.018 L0.6529,0.018 Z M0.6411,0 L0.7837,0 Q0.7873,0,0.7902,0.0015 T0.7943,0.005 T0.7956,0.0092 L0.7956,0.1208 Q0.7956,0.125,0.7917,0.1275 T0.7837,0.13 L0.6411,0.13 Q0.6359,0.13,0.6326,0.1271 T0.6292,0.1208 L0.6292,0.0092 Q0.6292,0.005,0.6331,0.0025 T0.6411,0 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.4325,0.0143 L0.4325,0.1158 L0.5628,0.1158 L0.5628,0.0143 L0.4325,0.0143 Z M0.4264,0.0042 L0.5695,0.0042 Q0.5721,0.0042,0.5739,0.0057 T0.5757,0.0092 L0.5757,0.1208 Q0.5757,0.1221,0.5752,0.1231 T0.5736,0.1248 T0.5716,0.1256 T0.5695,0.1258 L0.4264,0.1258 Q0.4238,0.1258,0.422,0.1244 T0.4202,0.1208 L0.4202,0.0092 Q0.4202,0.008,0.4207,0.0069 T0.4222,0.0052 T0.4243,0.0044 T0.4264,0.0042 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0.2178,0.0092 L0.3605,0.0092 L0.3605,0.1208 L0.2178,0.1208 L0.2178,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0,0.0092 L0.1432,0.0092 L0.1432,0.1208 L0,0.1208 L0,0.0092 Z M0.8574,0.3217 L0.8574,0.4098 L0.9691,0.4098 L0.9691,0.3217 L0.8574,0.3217 Z M0.8419,0.2978 L0.9846,0.2978 Q0.9892,0.2978,0.9928,0.2997 T0.9982,0.3043 T1,0.31 L1,0.4216 Q1,0.427,0.9951,0.4304 T0.9846,0.4337 L0.8419,0.4337 Q0.8352,0.4337,0.8308,0.4299 T0.8265,0.4216 L0.8265,0.31 Q0.8265,0.3062,0.829,0.3035 T0.835,0.2993 T0.8419,0.2978 Z M0.8574,0.888 L0.8574,0.9757 L0.9691,0.9757 L0.9691,0.888 L0.8574,0.888 Z M0.8419,0.8637 L0.9846,0.8637 Q0.9892,0.8637,0.9928,0.8658 T0.9982,0.8706 T1,0.8758 L1,0.9878 Q1,0.9929,0.9951,0.9964 T0.9846,1 L0.8419,1 Q0.8352,1,0.8308,0.996 T0.8265,0.9878 L0.8265,0.8758 Q0.8265,0.8708,0.8314,0.8672 T0.8419,0.8637 Z M0.6509,0.169 L0.6509,0.2706 L0.7806,0.2706 L0.7806,0.169 L0.6509,0.169 Z M0.6442,0.159 L0.7868,0.159 Q0.7899,0.159,0.7917,0.1604 T0.7935,0.164 L0.7935,0.2756 Q0.7935,0.2781,0.7915,0.2796 T0.7868,0.281 L0.6442,0.281 Q0.6416,0.281,0.6395,0.2794 T0.6375,0.2756 L0.6375,0.164 Q0.6375,0.1619,0.6398,0.1604 T0.6442,0.159 Z M0.6632,0.6044 L0.6632,0.6858 L0.7678,0.6858 L0.7678,0.6044 L0.6632,0.6044 Z M0.6442,0.5742 L0.7868,0.5742 Q0.7951,0.5742,0.8007,0.5791 T0.8064,0.5893 L0.8064,0.7013 Q0.8064,0.7076,0.8002,0.712 T0.7868,0.7164 L0.6442,0.7164 Q0.6359,0.7164,0.6305,0.7114 T0.6251,0.7013 L0.6251,0.5893 Q0.6251,0.5847,0.6282,0.5812 T0.6357,0.5759 T0.6442,0.5742 Z M0.4264,0.164 L0.5695,0.164 L0.5695,0.2756 L0.4264,0.2756 L0.4264,0.164 Z M0.4325,0.315 L0.4325,0.4165 L0.5628,0.4165 L0.5628,0.315 L0.4325,0.315 Z M0.4264,0.3049 L0.5695,0.3049 Q0.5721,0.3049,0.5739,0.3064 T0.5757,0.31 L0.5757,0.4216 Q0.5757,0.4237,0.5736,0.4251 T0.5695,0.4266 L0.4264,0.4266 Q0.4238,0.4266,0.422,0.4251 T0.4202,0.4216 L0.4202,0.31 Q0.4202,0.3087,0.4207,0.3077 T0.4222,0.306 T0.4243,0.3052 T0.4264,0.3049 Z M0.4264,0.4476 L0.5695,0.4476 L0.5695,0.5596 L0.4264,0.5596 L0.4264,0.4476 Z M0.4392,0.7408 L0.4392,0.8326 L0.5561,0.8326 L0.5561,0.7408 L0.4392,0.7408 Z M0.4264,0.7206 L0.5695,0.7206 Q0.5747,0.7206,0.5783,0.724 T0.5819,0.7307 L0.5819,0.8427 Q0.5819,0.8469,0.5778,0.8498 T0.5695,0.8528 L0.4264,0.8528 Q0.4207,0.8528,0.4171,0.8496 T0.4135,0.8427 L0.4135,0.7307 Q0.4135,0.7265,0.4176,0.7236 T0.4264,0.7206 Z M0.2178,0.164 L0.3605,0.164 L0.3605,0.2756 L0.2178,0.2756 L0.2178,0.164 Z M0.2178,0.31 L0.3605,0.31 L0.3605,0.4216 L0.2178,0.4216 L0.2178,0.31 Z M0.2178,0.5893 L0.3605,0.5893 L0.3605,0.7013 L0.2178,0.7013 L0.2178,0.5893 Z M0.2178,0.7307 L0.3605,0.7307 L0.3605,0.8427 L0.2178,0.8427 L0.2178,0.7307 Z M0.2178,0.8758 L0.3605,0.8758 L0.3605,0.9878 L0.2178,0.9878 L0.2178,0.8758 Z M0,0.164 L0.1432,0.164 L0.1432,0.2756 L0,0.2756 L0,0.164 Z M0,0.31 L0.1432,0.31 L0.1432,0.4216 L0,0.4216 L0,0.31 Z M0,0.4476 L0.1432,0.4476 L0.1432,0.5596 L0,0.5596 L0,0.4476 Z M0,0.5893 L0.1432,0.5893 L0.1432,0.7013 L0,0.7013 L0,0.5893 Z M0,0.7307 L0.1432,0.7307 L0.1432,0.8427 L0,0.8427 L0,0.7307 Z M0,0.8758 L0.1432,0.8758 L0.1432,0.9878 L0,0.9878 L0,0.8758 Z",type:0}},"\uE0C7":{type:5,data:{d:"M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.3476,0.018 L0.3476,0.112 L0.2276,0.112 L0.2276,0.018 L0.3476,0.018 Z M0.3594,0 L0.2163,0 Q0.2132,0,0.2104,0.0015 T0.2062,0.005 T0.2049,0.0092 L0.2049,0.1208 Q0.2049,0.1237,0.2067,0.1258 T0.2111,0.129 T0.2163,0.13 L0.3594,0.13 Q0.3641,0.13,0.3674,0.1271 T0.3708,0.1208 L0.3708,0.0092 Q0.3708,0.005,0.3669,0.0025 T0.3594,0 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.5675,0.0143 L0.5675,0.1158 L0.4377,0.1158 L0.4377,0.0143 L0.5675,0.0143 Z M0.5736,0.0042 L0.431,0.0042 Q0.4284,0.0042,0.4264,0.0057 T0.4243,0.0092 L0.4243,0.1208 Q0.4243,0.1221,0.4251,0.1231 T0.4269,0.1248 T0.4289,0.1256 T0.431,0.1258 L0.5736,0.1258 Q0.5767,0.1258,0.5785,0.1244 T0.5803,0.1208 L0.5803,0.0092 Q0.5803,0.008,0.5798,0.0069 T0.5783,0.0052 T0.5762,0.0044 T0.5736,0.0042 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M0.7827,0.0092 L0.6395,0.0092 L0.6395,0.1208 L0.7827,0.1208 L0.7827,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M1,0.0092 L0.8574,0.0092 L0.8574,0.1208 L1,0.1208 L1,0.0092 Z M0.1432,0.3217 L0.1432,0.4098 L0.0309,0.4098 L0.0309,0.3217 L0.1432,0.3217 Z M0.1586,0.2978 L0.0154,0.2978 Q0.0088,0.2978,0.0044,0.3016 T0,0.31 L0,0.4216 Q0,0.427,0.0049,0.4304 T0.0154,0.4337 L0.1586,0.4337 Q0.1648,0.4337,0.1694,0.4299 T0.174,0.4216 L0.174,0.31 Q0.174,0.3062,0.1715,0.3035 T0.1653,0.2993 T0.1586,0.2978 Z M0.1432,0.888 L0.1432,0.9757 L0.0309,0.9757 L0.0309,0.888 L0.1432,0.888 Z M0.1586,0.8637 L0.0154,0.8637 Q0.0108,0.8637,0.0072,0.8658 T0.0018,0.8706 T0,0.8758 L0,0.9878 Q0,0.9929,0.0049,0.9964 T0.0154,1 L0.1586,1 Q0.1648,1,0.1694,0.996 T0.174,0.9878 L0.174,0.8758 Q0.174,0.8725,0.1715,0.8695 T0.1653,0.8651 T0.1586,0.8637 Z M0.3496,0.169 L0.3496,0.2706 L0.2194,0.2706 L0.2194,0.169 L0.3496,0.169 Z M0.3563,0.159 L0.2132,0.159 Q0.2106,0.159,0.2088,0.1604 T0.207,0.164 L0.207,0.2756 Q0.207,0.2781,0.2091,0.2796 T0.2132,0.281 L0.3563,0.281 Q0.3589,0.281,0.3607,0.2794 T0.3625,0.2756 L0.3625,0.164 Q0.3625,0.1619,0.3605,0.1604 T0.3563,0.159 Z M0.3368,0.6044 L0.3368,0.6858 L0.2327,0.6858 L0.2327,0.6044 L0.3368,0.6044 Z M0.3563,0.5742 L0.2132,0.5742 Q0.2049,0.5742,0.1993,0.5791 T0.1936,0.5893 L0.1936,0.7013 Q0.1936,0.7076,0.2001,0.712 T0.2132,0.7164 L0.3563,0.7164 Q0.3641,0.7164,0.3697,0.7114 T0.3754,0.7013 L0.3754,0.5893 Q0.3754,0.5847,0.3723,0.5812 T0.3648,0.5759 T0.3563,0.5742 Z M0.5736,0.164 L0.431,0.164 L0.431,0.2756 L0.5736,0.2756 L0.5736,0.164 Z M0.5675,0.315 L0.5675,0.4165 L0.4377,0.4165 L0.4377,0.315 L0.5675,0.315 Z M0.5736,0.3049 L0.431,0.3049 Q0.4284,0.3049,0.4264,0.3064 T0.4243,0.31 L0.4243,0.4216 Q0.4243,0.4237,0.4266,0.4251 T0.431,0.4266 L0.5736,0.4266 Q0.5767,0.4266,0.5785,0.4251 T0.5803,0.4216 L0.5803,0.31 Q0.5803,0.3087,0.5798,0.3077 T0.5783,0.306 T0.5762,0.3052 T0.5736,0.3049 Z M0.5736,0.4476 L0.431,0.4476 L0.431,0.5596 L0.5736,0.5596 L0.5736,0.4476 Z M0.5613,0.7408 L0.5613,0.8326 L0.4439,0.8326 L0.4439,0.7408 L0.5613,0.7408 Z M0.5736,0.7206 L0.431,0.7206 Q0.4253,0.7206,0.4217,0.724 T0.4181,0.7307 L0.4181,0.8427 Q0.4181,0.8469,0.4222,0.8498 T0.431,0.8528 L0.5736,0.8528 Q0.5778,0.8528,0.5808,0.8511 T0.5855,0.8471 T0.587,0.8427 L0.587,0.7307 Q0.587,0.7265,0.5826,0.7236 T0.5736,0.7206 Z M0.7827,0.164 L0.6395,0.164 L0.6395,0.2756 L0.7827,0.2756 L0.7827,0.164 Z M0.7827,0.31 L0.6395,0.31 L0.6395,0.4216 L0.7827,0.4216 L0.7827,0.31 Z M0.7827,0.5893 L0.6395,0.5893 L0.6395,0.7013 L0.7827,0.7013 L0.7827,0.5893 Z M0.7827,0.7307 L0.6395,0.7307 L0.6395,0.8427 L0.7827,0.8427 L0.7827,0.7307 Z M0.7827,0.8758 L0.6395,0.8758 L0.6395,0.9878 L0.7827,0.9878 L0.7827,0.8758 Z M1,0.164 L0.8574,0.164 L0.8574,0.2756 L1,0.2756 L1,0.164 Z M1,0.31 L0.8574,0.31 L0.8574,0.4216 L1,0.4216 L1,0.31 Z M1,0.4476 L0.8574,0.4476 L0.8574,0.5596 L1,0.5596 L1,0.4476 Z M1,0.5893 L0.8574,0.5893 L0.8574,0.7013 L1,0.7013 L1,0.5893 Z M1,0.7307 L0.8574,0.7307 L0.8574,0.8427 L1,0.8427 L1,0.7307 Z M1,0.8758 L0.8574,0.8758 L0.8574,0.9878 L1,0.9878 L1,0.8758 Z",type:0}},"\uE0C8":{type:5,data:{d:"M0.0008,0.9979 Q0.0008,1,0.0032,1 Q0.0482,1,0.0602,0.9975 Q0.0667,0.9963,0.0847,0.9917 T0.1084,0.9859 Q0.1124,0.9851,0.1221,0.9851 L0.1253,0.9851 L0.1285,0.9851 Q0.1518,0.9851,0.1655,0.9809 Q0.1695,0.9801,0.1731,0.9788 T0.1791,0.9766 T0.1839,0.9747 T0.1888,0.9732 L0.1936,0.972 T0.1988,0.9712 T0.2048,0.971 L0.2096,0.971 Q0.2201,0.9714,0.2233,0.9714 T0.2345,0.9693 Q0.2386,0.9685,0.2502,0.9674 T0.2715,0.966 L0.2811,0.9656 Q0.2916,0.9647,0.2916,0.9573 Q0.2916,0.954,0.2892,0.9515 T0.2819,0.9486 Q0.2755,0.9482,0.2683,0.9482 Q0.2643,0.9482,0.2582,0.9484 T0.2498,0.9486 Q0.2426,0.9486,0.2402,0.9473 Q0.2386,0.9469,0.2317,0.943 T0.2161,0.937 Q0.2104,0.9357,0.1992,0.933 T0.1831,0.9291 Q0.1807,0.9287,0.1783,0.9285 T0.1739,0.928 T0.1703,0.9276 T0.1675,0.9272 T0.1655,0.9266 T0.1639,0.9253 Q0.1598,0.9216,0.1462,0.9216 L0.143,0.9216 L0.1398,0.9216 L0.1365,0.9216 Q0.1197,0.9195,0.1197,0.9154 L0.1197,0.9154 Q0.1205,0.9121,0.1373,0.9088 T0.1703,0.9054 Q0.1791,0.9054,0.1863,0.904 T0.1984,0.9011 T0.2064,0.8992 Q0.2402,0.8967,0.2506,0.8934 Q0.2538,0.8926,0.2602,0.8899 T0.2707,0.8872 Q0.2715,0.8872,0.2731,0.8876 Q0.2795,0.8884,0.2884,0.8884 Q0.3068,0.8884,0.3092,0.8826 L0.3092,0.8797 Q0.3092,0.8772,0.3076,0.8747 T0.3044,0.8706 T0.3028,0.8681 Q0.3076,0.8656,0.3237,0.8656 Q0.3317,0.8656,0.3622,0.8623 Q0.3655,0.8623,0.3867,0.8617 T0.4217,0.8602 Q0.4241,0.8598,0.4289,0.8598 L0.4345,0.8598 L0.441,0.8598 Q0.4578,0.8598,0.4667,0.8586 Q0.4739,0.8573,0.4851,0.8567 T0.5004,0.8552 Q0.5012,0.8552,0.5108,0.855 T0.5269,0.853 T0.5333,0.8461 Q0.5333,0.8445,0.5325,0.8432 T0.5309,0.8409 T0.5289,0.8391 T0.5261,0.8378 L0.5229,0.837 T0.5185,0.8362 T0.5133,0.8355 T0.5076,0.8349 T0.502,0.8341 Q0.4932,0.8324,0.4859,0.8324 Q0.4827,0.8324,0.4787,0.8328 T0.4731,0.8333 Q0.4707,0.8333,0.4683,0.8324 Q0.4482,0.825,0.4394,0.825 Q0.4369,0.825,0.4345,0.8254 Q0.4225,0.827,0.4153,0.827 Q0.4088,0.827,0.4064,0.8258 Q0.3976,0.8204,0.3751,0.8204 Q0.3703,0.8204,0.3667,0.8214 T0.3622,0.8225 Q0.3598,0.8225,0.3558,0.82 Q0.351,0.8175,0.3402,0.8167 T0.3177,0.8156 T0.3028,0.8146 L0.2956,0.8121 T0.2871,0.8094 T0.2771,0.8073 T0.2635,0.8063 Q0.261,0.8063,0.2586,0.8067 Q0.2378,0.8071,0.2273,0.8071 Q0.2072,0.8071,0.2024,0.8051 Q0.1976,0.8034,0.1932,0.8026 T0.1867,0.8013 T0.1847,0.8001 L0.1847,0.7993 Q0.1855,0.7984,0.1855,0.7978 T0.1859,0.7966 T0.1867,0.7959 T0.188,0.7955 T0.1896,0.7951 T0.1916,0.7949 T0.1944,0.7945 T0.1984,0.7941 T0.2032,0.7939 Q0.212,0.7926,0.2325,0.7918 T0.2562,0.7905 Q0.2691,0.7893,0.2787,0.7847 Q0.2835,0.7827,0.2968,0.7791 T0.3149,0.7756 Q0.3189,0.7756,0.3305,0.7733 T0.3446,0.771 L0.3454,0.771 Q0.3462,0.771,0.3498,0.7721 T0.3574,0.774 T0.3655,0.7748 Q0.3703,0.7748,0.3743,0.7731 T0.3823,0.7708 T0.3916,0.77 T0.3992,0.7694 Q0.4008,0.7694,0.4076,0.7688 T0.4205,0.7675 L0.4305,0.7665 Q0.4345,0.7657,0.4402,0.7617 T0.4458,0.7545 Q0.4458,0.7503,0.4353,0.7487 Q0.4297,0.7478,0.4209,0.7462 T0.4044,0.7433 T0.3904,0.7416 L0.3847,0.7416 Q0.3807,0.7416,0.3747,0.7422 T0.3663,0.7428 T0.3606,0.7424 Q0.3582,0.742,0.3562,0.7418 T0.3522,0.7412 T0.3482,0.7406 T0.3446,0.7401 T0.341,0.7397 T0.3373,0.7393 T0.3341,0.7391 L0.3293,0.7391 Q0.3277,0.7395,0.3253,0.7395 Q0.3205,0.7395,0.3052,0.7387 T0.2835,0.7379 Q0.2755,0.7379,0.253,0.7325 Q0.2506,0.7316,0.2462,0.731 T0.2382,0.7296 T0.2305,0.7271 T0.2181,0.7234 T0.2048,0.7196 T0.2,0.7155 Q0.2,0.7142,0.2016,0.713 Q0.208,0.7059,0.2285,0.6978 T0.2618,0.6898 Q0.2739,0.6898,0.2831,0.6848 T0.2956,0.6794 Q0.3028,0.6786,0.3289,0.6765 T0.3566,0.674 Q0.3582,0.674,0.3855,0.6723 T0.4406,0.6688 T0.4755,0.6653 Q0.5004,0.6607,0.5124,0.6599 L0.5165,0.6599 Q0.5213,0.6599,0.5277,0.6605 T0.5373,0.6611 Q0.543,0.6611,0.5494,0.6599 Q0.592,0.6524,0.6,0.6524 Q0.6064,0.6524,0.61,0.6499 T0.6161,0.6474 Q0.6177,0.6474,0.6261,0.6495 T0.6418,0.6516 L0.6482,0.6516 Q0.653,0.6512,0.6651,0.6506 T0.6876,0.6493 T0.7084,0.6477 T0.7229,0.645 Q0.7269,0.6437,0.7333,0.6437 Q0.7373,0.6437,0.7458,0.6443 T0.7574,0.645 Q0.7614,0.645,0.7631,0.6445 T0.7655,0.6441 Q0.7671,0.6441,0.7723,0.6448 T0.7823,0.6454 Q0.788,0.6454,0.7952,0.6445 Q0.8233,0.6408,0.8723,0.6367 Q0.9012,0.6342,0.9092,0.6342 L0.9165,0.6342 L0.9205,0.6342 Q0.9277,0.6342,0.9382,0.6323 T0.9526,0.6304 Q0.9534,0.6304,0.9594,0.6307 T0.9695,0.6309 Q0.9976,0.6309,0.9992,0.6234 Q1,0.6201,1,0.618 Q1,0.6089,0.9775,0.608 Q0.9703,0.6076,0.9582,0.6076 Q0.9518,0.6076,0.9394,0.6078 T0.9213,0.608 Q0.9036,0.608,0.8972,0.6072 Q0.894,0.6068,0.8871,0.6058 T0.8751,0.6039 T0.8627,0.6024 T0.8506,0.6018 L0.8474,0.6018 Q0.841,0.6022,0.8281,0.6041 T0.8112,0.606 Q0.8096,0.606,0.808,0.6056 Q0.7783,0.6002,0.7647,0.6002 Q0.7598,0.6002,0.7454,0.6006 T0.7261,0.601 Q0.7221,0.601,0.7145,0.6022 T0.7036,0.6035 Q0.6996,0.6035,0.6964,0.6022 Q0.6723,0.5902,0.6442,0.5902 Q0.6426,0.5902,0.6386,0.5904 T0.6337,0.5906 Q0.6273,0.5906,0.6249,0.5894 L0.6177,0.5856 T0.6116,0.5838 T0.6016,0.5825 T0.5855,0.5823 Q0.5719,0.5823,0.5574,0.5798 T0.5373,0.5774 L0.5341,0.5774 Q0.5293,0.5778,0.5213,0.5786 T0.51,0.5794 Q0.5036,0.5794,0.4988,0.5769 Q0.4932,0.574,0.4771,0.574 Q0.4723,0.574,0.4631,0.5745 T0.4498,0.5749 Q0.4418,0.5749,0.4394,0.5736 Q0.4353,0.5724,0.4305,0.5715 T0.4225,0.5707 T0.4145,0.5705 T0.4056,0.5699 Q0.4008,0.5691,0.398,0.5689 T0.394,0.5689 T0.3908,0.5686 T0.3855,0.5674 Q0.3831,0.567,0.3811,0.5662 T0.3779,0.5645 T0.3743,0.563 T0.3695,0.5624 Q0.3655,0.5624,0.3594,0.5635 T0.3494,0.5645 T0.3406,0.5635 T0.3325,0.5624 Q0.3309,0.5624,0.3301,0.5628 L0.3141,0.5628 Q0.2635,0.5628,0.2506,0.5612 Q0.2474,0.5608,0.2434,0.5608 Q0.2402,0.5608,0.2349,0.561 T0.2273,0.5612 Q0.2209,0.5612,0.2177,0.5595 Q0.2145,0.557,0.2145,0.5558 Q0.2169,0.5537,0.2345,0.5521 Q0.2876,0.5475,0.3028,0.5425 Q0.3068,0.5413,0.3149,0.5396 T0.3309,0.5367 T0.3478,0.5338 T0.3606,0.5309 Q0.3631,0.5305,0.3755,0.5259 T0.3968,0.5214 Q0.3992,0.5214,0.4024,0.5218 T0.4096,0.5222 Q0.4161,0.5222,0.4213,0.5209 T0.4313,0.5185 T0.4402,0.5172 Q0.4418,0.5168,0.447,0.5168 L0.4558,0.5168 T0.4639,0.5162 T0.4707,0.5141 T0.4731,0.5102 Q0.4731,0.5089,0.4723,0.5081 Q0.4699,0.5019,0.4627,0.4994 T0.4369,0.4965 Q0.4273,0.4961,0.4193,0.4946 T0.4048,0.4915 T0.3952,0.4894 Q0.392,0.4886,0.388,0.4886 Q0.3847,0.4886,0.3775,0.4892 T0.3647,0.4898 Q0.3598,0.4898,0.355,0.4894 Q0.3165,0.4861,0.3141,0.4853 Q0.2779,0.4774,0.2715,0.4774 Q0.2699,0.4774,0.2683,0.4776 T0.2627,0.4778 Q0.2498,0.4778,0.2281,0.4762 Q0.2241,0.4749,0.2233,0.4737 Q0.2249,0.472,0.2321,0.4708 Q0.2402,0.4695,0.2554,0.4695 L0.2675,0.4695 L0.2795,0.4695 Q0.294,0.4695,0.2996,0.4679 Q0.3141,0.4629,0.3373,0.4616 Q0.3414,0.4612,0.3466,0.4606 T0.3582,0.4589 T0.3687,0.4562 T0.3727,0.4525 Q0.3727,0.4509,0.3703,0.4492 Q0.3639,0.4455,0.3474,0.4419 T0.3213,0.4384 L0.3157,0.4384 L0.31,0.4384 Q0.294,0.4384,0.2859,0.4363 Q0.2795,0.4347,0.2691,0.4347 L0.2618,0.4347 L0.2546,0.4347 Q0.2458,0.4347,0.2394,0.4338 Q0.2257,0.4318,0.2193,0.4303 T0.2092,0.4266 T0.2052,0.4224 T0.2024,0.4154 T0.196,0.4052 Q0.1912,0.3994,0.1867,0.3949 T0.1807,0.3884 T0.1791,0.3849 Q0.1791,0.3824,0.1839,0.3745 Q0.1871,0.3687,0.1944,0.3637 T0.2072,0.3561 T0.2221,0.3501 T0.2321,0.3463 Q0.2353,0.3455,0.241,0.3445 T0.2522,0.3428 T0.2639,0.3411 T0.2731,0.3384 T0.2956,0.3351 T0.3197,0.3326 Q0.3213,0.3322,0.3237,0.3322 T0.3325,0.3331 T0.3414,0.3339 Q0.3478,0.3339,0.3518,0.3314 Q0.3606,0.3264,0.3703,0.3264 Q0.3775,0.3264,0.3847,0.3293 Q0.3888,0.331,0.396,0.331 Q0.4024,0.331,0.4116,0.3295 T0.4265,0.3266 T0.4414,0.3227 T0.4514,0.3202 Q0.4578,0.3181,0.4839,0.3152 T0.5112,0.3123 T0.5177,0.3119 T0.5285,0.3111 T0.5398,0.3096 T0.549,0.3065 T0.5526,0.3015 Q0.5526,0.2999,0.5518,0.2982 Q0.547,0.2903,0.5386,0.2883 T0.5165,0.2862 Q0.5116,0.2862,0.5056,0.2866 T0.4972,0.287 Q0.4884,0.287,0.4787,0.2833 Q0.4594,0.2758,0.4265,0.2758 Q0.4169,0.2758,0.4129,0.2766 Q0.4112,0.2771,0.4104,0.2771 Q0.4088,0.2771,0.4056,0.2748 T0.3968,0.2721 Q0.3695,0.2696,0.359,0.2659 Q0.355,0.2646,0.351,0.2646 T0.3426,0.2661 T0.3365,0.2675 T0.3333,0.2659 Q0.3277,0.2605,0.3116,0.2553 T0.2859,0.2501 L0.2843,0.2501 L0.2819,0.2501 Q0.2779,0.2501,0.2723,0.2493 T0.2586,0.2472 T0.2474,0.2455 Q0.1799,0.236,0.1719,0.236 Q0.1663,0.236,0.1606,0.235 T0.1502,0.2316 T0.1454,0.2265 Q0.1454,0.2223,0.1558,0.2173 Q0.1679,0.2115,0.1859,0.2092 T0.2185,0.207 T0.2353,0.2057 Q0.2369,0.2049,0.2502,0.2041 T0.2799,0.2022 T0.302,0.2007 Q0.3084,0.1999,0.3245,0.1954 T0.347,0.1908 Q0.3502,0.1908,0.3534,0.1916 Q0.3663,0.197,0.388,0.197 Q0.404,0.197,0.4088,0.1929 Q0.4096,0.192,0.4108,0.192 T0.4177,0.1945 T0.4277,0.197 T0.4369,0.1949 Q0.4442,0.1908,0.4843,0.1875 Q0.4892,0.1871,0.4968,0.1839 T0.5084,0.1808 L0.5108,0.1808 Q0.5213,0.1825,0.5454,0.1825 Q0.5823,0.1825,0.5992,0.1779 Q0.6096,0.1746,0.6217,0.1746 Q0.6249,0.1746,0.6309,0.1748 T0.6394,0.175 Q0.6434,0.175,0.6466,0.1746 Q0.6474,0.1746,0.6594,0.1738 T0.6787,0.1713 T0.6892,0.1659 Q0.6924,0.1634,0.6924,0.1593 Q0.6924,0.1547,0.6888,0.151 T0.6795,0.1472 Q0.6779,0.1472,0.6763,0.1477 Q0.6699,0.1493,0.6667,0.1493 Q0.661,0.1493,0.6506,0.1456 Q0.6329,0.1394,0.6129,0.1394 Q0.6048,0.1394,0.5992,0.1377 T0.5888,0.136 T0.5803,0.1377 T0.5751,0.1394 Q0.5711,0.1394,0.5639,0.1369 Q0.5365,0.1273,0.5116,0.1273 Q0.4876,0.1273,0.4667,0.1253 Q0.4627,0.1248,0.4542,0.1211 T0.4337,0.1174 L0.4305,0.1174 Q0.4217,0.1178,0.4153,0.119 T0.408,0.1203 Q0.4056,0.1203,0.4,0.117 Q0.3976,0.1153,0.396,0.1136 T0.3932,0.1112 T0.39,0.1099 T0.3847,0.1095 Q0.3799,0.1095,0.3687,0.1103 Q0.355,0.1112,0.3434,0.1141 T0.3301,0.117 Q0.3261,0.117,0.3229,0.1145 Q0.3181,0.1116,0.3052,0.1087 T0.2787,0.1043 T0.2546,0.1029 Q0.2506,0.1029,0.2474,0.1033 Q0.2418,0.1037,0.2349,0.1054 T0.2249,0.107 Q0.2225,0.107,0.2193,0.1066 Q0.2024,0.1024,0.1863,0.1024 Q0.1823,0.1024,0.1562,0.0971 T0.1269,0.0917 T0.1161,0.0896 T0.106,0.0854 Q0.1076,0.0846,0.11,0.0842 Q0.1173,0.0821,0.1261,0.0821 Q0.1301,0.0821,0.1369,0.0825 T0.1462,0.083 Q0.1494,0.083,0.151,0.0825 T0.1831,0.0813 T0.2201,0.0784 T0.243,0.074 T0.2667,0.0718 Q0.2707,0.0718,0.2755,0.0724 T0.2843,0.073 T0.2936,0.0724 T0.3024,0.0705 T0.3072,0.068 T0.3092,0.0657 T0.3108,0.0647 Q0.3116,0.0643,0.3133,0.0643 L0.3157,0.0643 L0.3181,0.0643 Q0.3446,0.0643,0.3446,0.0556 T0.3205,0.0469 L0.3189,0.0469 Q0.3157,0.0469,0.3157,0.0465 Q0.3084,0.0427,0.2835,0.0386 Q0.2739,0.0369,0.2675,0.0342 T0.2538,0.0299 Q0.249,0.029,0.245,0.029 T0.2378,0.0303 T0.2321,0.0315 Q0.2305,0.0315,0.2273,0.0307 Q0.2201,0.0286,0.2004,0.0272 T0.1775,0.0245 Q0.1735,0.0228,0.1663,0.0226 T0.1522,0.0197 T0.1345,0.0145 T0.1124,0.0079 Q0.0996,0.0033,0.0671,0.0044 T0.0297,0.0041 Q0.0169,0,0.0024,0.0017 Q0,0.0021,0,0.0037 Z M0.0064,0.9954 L0.0056,0.0058 Q0.0177,0.005,0.0281,0.0083 Q0.0345,0.01,0.0675,0.0089 T0.1108,0.012 Q0.1221,0.0162,0.1329,0.0187 T0.1498,0.0236 T0.1606,0.0268 T0.1691,0.0274 T0.1759,0.0286 Q0.1807,0.0303,0.2,0.0315 T0.2257,0.0348 Q0.2305,0.0361,0.2337,0.0361 Q0.2361,0.0361,0.2398,0.0348 T0.2466,0.0336 Q0.249,0.0336,0.2522,0.034 Q0.2586,0.0353,0.2655,0.0382 T0.2827,0.0427 Q0.3068,0.0469,0.3124,0.0502 Q0.3157,0.0523,0.3237,0.0523 L0.3277,0.0523 L0.3309,0.0523 Q0.3398,0.0523,0.3398,0.0556 Q0.3398,0.0581,0.3341,0.0587 T0.3205,0.0593 T0.3092,0.0603 T0.3036,0.0643 T0.2932,0.068 Q0.2884,0.0684,0.2843,0.0684 Q0.2811,0.0684,0.2763,0.0678 T0.2667,0.0672 Q0.2586,0.0672,0.2422,0.0697 T0.2177,0.0747 Q0.2137,0.0759,0.1831,0.0767 T0.1494,0.0784 L0.1474,0.0784 T0.1398,0.078 T0.1285,0.0776 Q0.1173,0.0776,0.1084,0.08 Q0.1004,0.0817,0.1004,0.0846 Q0.1004,0.0867,0.104,0.0888 T0.1124,0.0923 T0.1213,0.0948 T0.1269,0.0958 T0.155,0.1014 T0.1863,0.107 Q0.2016,0.107,0.2185,0.1107 Q0.2217,0.1116,0.2257,0.1116 Q0.2289,0.1116,0.2365,0.1099 T0.2482,0.1074 L0.2538,0.1074 Q0.2683,0.1074,0.2912,0.1107 T0.3197,0.1182 Q0.3245,0.1215,0.3301,0.1215 Q0.3333,0.1215,0.3454,0.1184 T0.3687,0.1145 Q0.3735,0.1141,0.3779,0.1139 T0.3843,0.1134 T0.3871,0.1132 Q0.3896,0.1132,0.39,0.1136 T0.392,0.1161 T0.3968,0.1203 Q0.404,0.1248,0.408,0.1248 Q0.4096,0.1248,0.4165,0.1234 T0.4305,0.1215 L0.4337,0.1215 Q0.4442,0.1215,0.4518,0.1251 T0.4659,0.1294 Q0.4876,0.1319,0.5116,0.1319 T0.5622,0.141 Q0.5703,0.1439,0.5751,0.1439 Q0.5791,0.1439,0.5831,0.1423 T0.5888,0.1406 Q0.592,0.1406,0.5944,0.1412 T0.6012,0.1427 T0.6129,0.1435 Q0.6313,0.1435,0.649,0.1497 Q0.6602,0.1535,0.6667,0.1535 Q0.6715,0.1535,0.6779,0.1522 Q0.6787,0.1518,0.6795,0.1518 Q0.6827,0.1518,0.6847,0.1541 T0.6867,0.1593 Q0.6867,0.1618,0.6851,0.1634 Q0.6835,0.1655,0.6775,0.1667 T0.6598,0.169 T0.6458,0.1705 T0.6402,0.1709 Q0.6378,0.1709,0.6321,0.1705 T0.6225,0.1701 Q0.6096,0.1701,0.5968,0.1738 Q0.5823,0.1779,0.5446,0.1779 Q0.5205,0.1779,0.5116,0.1767 Q0.51,0.1763,0.5084,0.1763 Q0.5028,0.1763,0.4944,0.1796 T0.4835,0.1829 L0.4771,0.1833 T0.4659,0.1844 L0.4546,0.1858 T0.443,0.1881 T0.4337,0.1912 Q0.4321,0.1916,0.4305,0.1916 Q0.4273,0.1916,0.4209,0.1895 T0.412,0.1875 Q0.408,0.1875,0.4056,0.19 Q0.4024,0.1925,0.388,0.1925 Q0.3663,0.1925,0.3558,0.1879 Q0.3518,0.1862,0.3462,0.1862 Q0.3373,0.1862,0.3213,0.191 T0.3012,0.1962 Q0.2964,0.1966,0.2799,0.1976 T0.2494,0.1997 T0.2313,0.2024 L0.2313,0.2024 L0.2293,0.2024 L0.2261,0.2024 L0.2217,0.2024 Q0.204,0.2024,0.1851,0.2047 T0.1526,0.2136 Q0.1406,0.2198,0.1406,0.226 Q0.1406,0.2319,0.1518,0.2362 T0.1719,0.2406 Q0.1791,0.2406,0.2466,0.2497 Q0.2498,0.2501,0.2574,0.2516 T0.2711,0.2538 T0.2819,0.2547 L0.2851,0.2547 Q0.2851,0.2543,0.2859,0.2543 Q0.294,0.2543,0.3096,0.2592 T0.3285,0.2684 Q0.3317,0.2721,0.3373,0.2721 Q0.3406,0.2721,0.3462,0.2708 T0.3542,0.2696 Q0.3558,0.2696,0.3574,0.27 Q0.3679,0.2737,0.3968,0.2762 Q0.3984,0.2766,0.4004,0.2779 L0.4044,0.2804 T0.4096,0.2816 Q0.412,0.2816,0.4145,0.2808 Q0.4177,0.2804,0.4249,0.2804 Q0.4586,0.2804,0.4763,0.287 Q0.4867,0.2912,0.4972,0.2912 Q0.5004,0.2912,0.5064,0.2908 T0.5165,0.2903 Q0.5293,0.2903,0.5361,0.2922 T0.547,0.3003 L0.547,0.3011 Q0.547,0.3036,0.5414,0.3049 T0.5249,0.3067 T0.51,0.3082 Q0.5092,0.3082,0.4831,0.3111 T0.4498,0.3161 Q0.408,0.3264,0.3952,0.3264 Q0.3896,0.3264,0.3863,0.3252 Q0.3783,0.3223,0.3703,0.3223 Q0.359,0.3223,0.3494,0.3277 Q0.347,0.3293,0.343,0.3293 Q0.3414,0.3293,0.3353,0.3285 T0.3253,0.3277 T0.3181,0.3285 Q0.3149,0.3289,0.2952,0.3306 T0.2699,0.3347 Q0.2675,0.3364,0.2598,0.3372 T0.2434,0.3395 T0.2297,0.3426 L0.2205,0.3461 T0.2048,0.3523 T0.1908,0.3606 T0.1791,0.3725 Q0.1743,0.3803,0.1743,0.3845 Q0.1743,0.3874,0.1763,0.3901 T0.1827,0.3976 T0.1912,0.4073 T0.198,0.4189 T0.2032,0.4276 T0.2137,0.4326 T0.2386,0.438 Q0.2458,0.4392,0.2562,0.4392 L0.2639,0.4392 L0.2699,0.4392 Q0.2795,0.4392,0.2843,0.4405 Q0.2932,0.443,0.3092,0.443 L0.3157,0.443 L0.3213,0.443 Q0.3293,0.443,0.3458,0.4463 T0.3671,0.4525 Q0.3598,0.4558,0.3373,0.4571 Q0.3124,0.4587,0.298,0.4637 Q0.294,0.465,0.2811,0.465 L0.2703,0.465 L0.2586,0.465 Q0.241,0.465,0.2305,0.4666 Q0.2185,0.4687,0.2185,0.4728 Q0.2185,0.4745,0.2205,0.4764 T0.2241,0.4791 L0.2265,0.4803 L0.2273,0.4803 Q0.2506,0.4824,0.261,0.4824 Q0.2675,0.4824,0.2699,0.482 Q0.2699,0.4815,0.2707,0.4815 Q0.2771,0.4815,0.3124,0.4898 Q0.3173,0.4907,0.3229,0.4913 T0.3386,0.4925 T0.3542,0.4936 T0.3663,0.494 Q0.3711,0.494,0.3787,0.4936 T0.3888,0.4932 Q0.392,0.4932,0.3944,0.4936 T0.4032,0.4959 T0.4181,0.499 T0.4369,0.5006 Q0.449,0.501,0.455,0.5021 T0.4639,0.5048 T0.4675,0.5093 L0.4675,0.5097 Q0.4675,0.5106,0.4663,0.511 T0.4622,0.5116 L0.4566,0.512 T0.4486,0.5124 T0.4402,0.5127 Q0.4345,0.5131,0.4257,0.5153 T0.4096,0.5176 Q0.4064,0.5176,0.4024,0.5172 L0.3968,0.5172 Q0.3863,0.5172,0.3731,0.522 T0.359,0.5268 Q0.355,0.528,0.3317,0.5321 T0.3004,0.5388 Q0.2867,0.5433,0.2337,0.5479 Q0.2096,0.55,0.2096,0.5562 Q0.2096,0.5591,0.2145,0.5628 Q0.2193,0.5662,0.2289,0.5662 Q0.2313,0.5662,0.2373,0.5657 T0.2458,0.5653 L0.2498,0.5653 Q0.2635,0.5674,0.3124,0.5674 Q0.3261,0.5674,0.3301,0.567 L0.3317,0.567 Q0.3341,0.567,0.3394,0.568 T0.3486,0.5691 Q0.3542,0.5691,0.3606,0.5678 T0.3699,0.5666 T0.3759,0.5686 T0.3839,0.5718 T0.3908,0.573 T0.3964,0.5734 T0.4048,0.5742 T0.4165,0.5751 T0.4277,0.5757 T0.4369,0.5778 Q0.4426,0.5794,0.4538,0.5794 Q0.4586,0.5794,0.4679,0.5792 T0.4811,0.579 Q0.4924,0.579,0.4956,0.5807 Q0.502,0.584,0.5108,0.584 L0.5137,0.584 T0.5173,0.5838 L0.5205,0.5834 L0.5237,0.583 L0.5269,0.5825 T0.5305,0.5821 T0.5341,0.5819 L0.5365,0.5819 Q0.5414,0.5819,0.5562,0.5842 T0.5855,0.5865 Q0.5912,0.5865,0.5948,0.5867 T0.6012,0.5869 T0.6064,0.5873 T0.61,0.5877 T0.6133,0.5886 T0.6157,0.5896 L0.6185,0.591 T0.6217,0.5931 Q0.6265,0.5952,0.6369,0.5952 L0.6418,0.5952 L0.6466,0.5952 Q0.6723,0.5952,0.6932,0.606 Q0.698,0.608,0.7044,0.608 Q0.7084,0.608,0.7161,0.6068 T0.7261,0.6056 Q0.7309,0.6056,0.7454,0.6051 T0.7647,0.6047 Q0.7775,0.6047,0.8072,0.6101 Q0.8096,0.6105,0.812,0.6105 Q0.8177,0.6105,0.8297,0.6085 T0.8474,0.606 L0.8506,0.606 Q0.8562,0.606,0.8647,0.607 T0.8831,0.6095 T0.8964,0.6118 Q0.9036,0.6122,0.9221,0.6122 L0.9406,0.6122 L0.959,0.6122 L0.9767,0.6122 Q0.9952,0.613,0.9952,0.6172 Q0.9952,0.6176,0.9944,0.6221 Q0.9936,0.6259,0.9574,0.6259 L0.955,0.6259 L0.9526,0.6259 Q0.9478,0.6259,0.9373,0.628 T0.9205,0.63 L0.9173,0.63 Q0.9133,0.6296,0.9084,0.6296 Q0.9004,0.6296,0.8715,0.6321 Q0.8233,0.6367,0.7944,0.64 Q0.7888,0.6408,0.7839,0.6408 T0.7739,0.6402 T0.7671,0.6396 Q0.7639,0.6396,0.7622,0.64 Q0.7614,0.6404,0.759,0.6404 Q0.7574,0.6404,0.749,0.6398 T0.7357,0.6392 Q0.7261,0.6392,0.7213,0.6412 Q0.7173,0.6425,0.7032,0.6437 T0.6711,0.6458 T0.6474,0.647 Q0.645,0.6474,0.6418,0.6474 Q0.6353,0.6474,0.6277,0.6454 T0.6161,0.6433 Q0.6104,0.6433,0.6072,0.6456 T0.6,0.6479 Q0.5912,0.6479,0.5486,0.6557 Q0.5422,0.657,0.5373,0.657 Q0.5349,0.657,0.5289,0.6564 T0.5173,0.6557 L0.5124,0.6557 Q0.4996,0.6562,0.4739,0.6611 Q0.4675,0.6624,0.4398,0.6645 T0.3851,0.668 T0.3558,0.6698 Q0.355,0.6698,0.3285,0.6721 T0.2948,0.6752 Q0.2892,0.6761,0.2843,0.6786 T0.2747,0.6831 T0.2618,0.6852 Q0.253,0.6852,0.2394,0.6893 T0.2141,0.6993 T0.1968,0.7105 Q0.1944,0.713,0.1944,0.7155 Q0.1944,0.7196,0.2012,0.7229 T0.2177,0.7285 T0.2281,0.7308 Q0.2321,0.7329,0.2365,0.7339 T0.245,0.7356 T0.2514,0.7366 Q0.2522,0.737,0.2554,0.7379 T0.2614,0.7393 T0.2679,0.7406 T0.2755,0.7416 T0.2835,0.742 Q0.29,0.742,0.3048,0.7428 T0.3253,0.7437 L0.3301,0.7437 L0.3333,0.7437 Q0.3341,0.7437,0.3598,0.7466 Q0.3639,0.7474,0.3671,0.7474 T0.3759,0.7466 T0.3855,0.7457 Q0.388,0.7457,0.3904,0.7462 Q0.3984,0.7466,0.4137,0.7495 T0.4349,0.7532 T0.441,0.7557 Q0.441,0.7599,0.4289,0.7619 Q0.4257,0.7628,0.4197,0.7634 T0.4072,0.7644 T0.3984,0.7652 Q0.3968,0.7652,0.3912,0.7655 T0.3811,0.7665 T0.3719,0.7694 Q0.3695,0.7702,0.3655,0.7702 T0.3546,0.7686 T0.3462,0.7665 L0.3446,0.7665 Q0.3406,0.7665,0.3293,0.769 T0.3149,0.7715 Q0.3084,0.7715,0.2952,0.7748 T0.2763,0.781 Q0.2675,0.7847,0.2554,0.7864 Q0.253,0.7864,0.2325,0.7874 T0.2024,0.7893 Q0.2008,0.7893,0.1984,0.7897 T0.1948,0.7901 T0.1916,0.7903 T0.1888,0.7908 T0.1867,0.7914 T0.1847,0.7922 L0.1831,0.793 T0.1819,0.7943 L0.1811,0.7959 T0.1799,0.7976 Q0.1791,0.7993,0.1791,0.8001 Q0.1791,0.8034,0.1831,0.8051 T0.1932,0.8078 T0.2,0.8092 Q0.2072,0.8117,0.2257,0.8117 Q0.2337,0.8117,0.2586,0.8109 L0.2635,0.8109 Q0.2707,0.8109,0.2767,0.8117 T0.2859,0.8136 T0.2936,0.8163 T0.3012,0.8187 Q0.3068,0.82,0.3277,0.8206 T0.3526,0.8237 Q0.3582,0.8266,0.3622,0.8266 Q0.3647,0.8266,0.3687,0.8258 T0.3751,0.825 Q0.396,0.825,0.404,0.8291 Q0.408,0.8316,0.4161,0.8316 Q0.4217,0.8316,0.4353,0.8295 L0.4394,0.8295 Q0.4466,0.8295,0.4659,0.8366 Q0.4699,0.8378,0.4747,0.8378 Q0.4763,0.8378,0.4807,0.8374 T0.4884,0.837 Q0.494,0.837,0.5004,0.8382 Q0.5052,0.8391,0.5124,0.8399 Q0.5221,0.8411,0.5249,0.8422 T0.5277,0.8461 Q0.5277,0.8482,0.5233,0.849 T0.5112,0.8501 T0.4996,0.8511 Q0.4964,0.8519,0.4851,0.8523 T0.4659,0.8544 Q0.4578,0.8557,0.4402,0.8557 L0.4349,0.8557 L0.4297,0.8557 L0.4209,0.8557 Q0.408,0.8565,0.3867,0.8571 T0.3614,0.8582 Q0.3317,0.8611,0.3237,0.8611 L0.3225,0.8611 L0.3213,0.8611 Q0.2972,0.8611,0.2972,0.8681 Q0.2972,0.8714,0.3008,0.8747 T0.3044,0.8805 Q0.3044,0.881,0.3036,0.8814 Q0.3012,0.8839,0.2851,0.8839 Q0.2779,0.8839,0.2739,0.883 Q0.2723,0.8826,0.2699,0.8826 T0.2643,0.8835 T0.259,0.8853 T0.2542,0.8876 T0.249,0.8893 Q0.2386,0.8922,0.2056,0.8946 Q0.2016,0.8951,0.1916,0.898 T0.1703,0.9009 Q0.1518,0.9009,0.1337,0.9044 T0.1149,0.9146 L0.1149,0.9158 Q0.1149,0.9175,0.1157,0.9187 T0.1173,0.921 T0.1201,0.9226 T0.1233,0.9237 T0.1269,0.9245 L0.1305,0.9251 T0.1337,0.9255 L0.1357,0.9258 Q0.139,0.9262,0.1446,0.9262 T0.1542,0.9266 T0.1598,0.9282 Q0.1614,0.9303,0.1647,0.9314 T0.1739,0.9328 T0.1815,0.9336 Q0.1863,0.9345,0.1976,0.9372 T0.2145,0.9415 Q0.2225,0.9432,0.2285,0.9467 T0.2378,0.9515 Q0.2426,0.9531,0.2514,0.9531 Q0.2538,0.9531,0.2602,0.9529 T0.2707,0.9527 Q0.2763,0.9527,0.2815,0.9531 T0.2867,0.9569 T0.2811,0.9614 Q0.2434,0.9627,0.2329,0.9652 Q0.2249,0.9672,0.2241,0.9672 L0.2096,0.9668 L0.2056,0.9668 Q0.2,0.9668,0.196,0.9672 T0.1884,0.9687 T0.1815,0.9708 T0.1731,0.9737 T0.1639,0.9772 Q0.1518,0.9805,0.1285,0.9805 L0.1253,0.9805 Q0.1124,0.9805,0.1076,0.9818 Q0.1012,0.983,0.0831,0.9876 T0.0594,0.9934 Q0.049,0.9954,0.0064,0.9954 Z M0.0032,0.9979 L0.0024,0.0037 Q0.0161,0.0021,0.0289,0.0062 Q0.0337,0.0075,0.0671,0.0066 T0.1116,0.01 T0.1337,0.0166 T0.151,0.022 Q0.1582,0.0249,0.1659,0.0251 T0.1767,0.0265 Q0.1807,0.0278,0.2,0.0292 T0.2265,0.0328 Q0.2305,0.034,0.2329,0.034 T0.239,0.0326 T0.2458,0.0311 T0.253,0.0319 Q0.2578,0.0332,0.2614,0.0342 T0.2667,0.0363 T0.2723,0.0384 T0.2827,0.0406 Q0.3084,0.0448,0.3141,0.0485 Q0.3149,0.0494,0.3225,0.0494 T0.3361,0.0504 T0.3422,0.0554 T0.3357,0.0606 T0.3205,0.0618 T0.31,0.0626 Q0.3092,0.0626,0.3056,0.0659 T0.2932,0.0701 Q0.2884,0.0709,0.2843,0.0709 Q0.2811,0.0709,0.2759,0.0701 T0.2667,0.0693 Q0.2586,0.0693,0.2422,0.0718 T0.2193,0.0763 Q0.2137,0.0784,0.1831,0.079 T0.1502,0.0805 Q0.1494,0.0809,0.147,0.0809 Q0.1454,0.0809,0.1386,0.0803 T0.1277,0.0796 Q0.1173,0.0796,0.1092,0.0821 Q0.1036,0.0834,0.1036,0.0854 Q0.1036,0.0879,0.1129,0.0908 T0.1269,0.0937 Q0.1293,0.0937,0.1558,0.0991 T0.1863,0.1045 Q0.2016,0.1045,0.2193,0.1087 Q0.2225,0.1091,0.2249,0.1091 Q0.2289,0.1091,0.2357,0.1076 T0.2482,0.1054 Q0.2506,0.1049,0.2546,0.1049 Q0.2699,0.1049,0.2924,0.1085 T0.3213,0.1165 Q0.3253,0.119,0.3301,0.119 Q0.3325,0.119,0.3446,0.1161 T0.3687,0.1124 Q0.3831,0.1112,0.3863,0.1112 T0.3908,0.1118 T0.3936,0.1145 T0.3984,0.1186 Q0.4048,0.1228,0.408,0.1228 Q0.4096,0.1228,0.4161,0.1213 T0.4305,0.1195 L0.4337,0.1195 Q0.4426,0.1195,0.4474,0.1211 T0.4566,0.1246 T0.4667,0.1273 Q0.4876,0.1298,0.5116,0.1298 Q0.5365,0.1298,0.5631,0.1389 Q0.5703,0.1414,0.5751,0.1414 Q0.5783,0.1414,0.5819,0.1398 T0.5888,0.1381 Q0.5928,0.1381,0.5984,0.1398 T0.6129,0.1414 Q0.6321,0.1414,0.6498,0.1477 Q0.661,0.1514,0.6667,0.1514 Q0.6707,0.1514,0.6771,0.1501 Q0.6787,0.1497,0.6795,0.1497 Q0.6835,0.1497,0.6863,0.1526 T0.6892,0.1593 Q0.6892,0.1626,0.6876,0.1647 Q0.6851,0.1672,0.6799,0.1686 T0.6695,0.1705 T0.6566,0.1715 T0.6458,0.1725 Q0.6434,0.173,0.6402,0.173 Q0.6378,0.173,0.6317,0.1727 T0.6225,0.1725 Q0.6096,0.1725,0.5984,0.1759 Q0.5823,0.1804,0.5454,0.1804 Q0.5205,0.1804,0.5116,0.1788 L0.5084,0.1788 Q0.5036,0.1788,0.4956,0.1819 T0.4843,0.185 Q0.4434,0.1887,0.4353,0.1929 Q0.4321,0.1945,0.4289,0.1945 T0.4197,0.1922 T0.4112,0.19 T0.4072,0.1912 Q0.4032,0.1949,0.388,0.1949 Q0.3663,0.1949,0.3542,0.19 Q0.351,0.1883,0.3462,0.1883 Q0.3406,0.1883,0.3321,0.1906 T0.3153,0.1954 T0.302,0.1983 Q0.2972,0.1991,0.2859,0.1997 T0.2643,0.201 T0.245,0.2024 T0.2337,0.2041 Q0.2321,0.2045,0.2181,0.2045 T0.1851,0.207 T0.1542,0.2157 Q0.143,0.2211,0.143,0.226 Q0.143,0.2314,0.153,0.2348 T0.1719,0.2381 Q0.1791,0.2381,0.2474,0.2476 Q0.2506,0.248,0.2558,0.2489 T0.2651,0.2505 T0.2739,0.252 T0.2819,0.2526 Q0.2835,0.2526,0.2851,0.2522 L0.2859,0.2522 Q0.2948,0.2522,0.3104,0.2574 T0.3309,0.2671 Q0.3333,0.2696,0.3365,0.2696 Q0.339,0.2696,0.3442,0.2684 T0.3526,0.2671 T0.3582,0.2679 Q0.3687,0.2717,0.3968,0.2742 Q0.4,0.2746,0.4036,0.2771 T0.4096,0.2796 Q0.4112,0.2796,0.4137,0.2787 Q0.4169,0.2779,0.4257,0.2779 Q0.4594,0.2779,0.4779,0.2849 Q0.4876,0.2891,0.4972,0.2891 Q0.4996,0.2891,0.506,0.2887 T0.5165,0.2883 Q0.5253,0.2883,0.5305,0.2889 T0.541,0.292 T0.5494,0.299 L0.5494,0.3015 Q0.5494,0.3036,0.5466,0.3051 T0.5386,0.3073 T0.5285,0.3086 T0.5181,0.3094 T0.5108,0.3102 Q0.5068,0.3107,0.494,0.3121 L0.4683,0.315 T0.4506,0.3181 Q0.449,0.3181,0.4426,0.32 T0.4313,0.3229 T0.4197,0.3256 T0.4064,0.3281 T0.3952,0.3289 Q0.3888,0.3289,0.3855,0.3273 Q0.3783,0.3243,0.3703,0.3243 Q0.3598,0.3243,0.351,0.3297 Q0.3478,0.3314,0.3422,0.3314 Q0.3398,0.3314,0.3337,0.3306 T0.3245,0.3297 T0.3189,0.3306 Q0.3149,0.3314,0.2956,0.3328 T0.2715,0.3368 Q0.2691,0.338,0.2635,0.3391 T0.2522,0.3407 T0.2406,0.3424 T0.2313,0.3447 Q0.2305,0.3447,0.2213,0.348 T0.206,0.354 T0.1924,0.3621 T0.1815,0.3733 Q0.1767,0.3816,0.1767,0.3849 Q0.1767,0.387,0.1783,0.3893 T0.1847,0.3961 T0.1936,0.4063 T0.2004,0.4181 T0.2052,0.4264 T0.2145,0.4309 T0.2386,0.4359 Q0.2458,0.4372,0.2554,0.4372 Q0.2578,0.4372,0.2627,0.437 T0.2699,0.4367 Q0.2795,0.4367,0.2851,0.4384 Q0.294,0.4409,0.3092,0.4409 Q0.3116,0.4409,0.3157,0.4407 T0.3213,0.4405 Q0.3301,0.4405,0.3466,0.444 T0.3687,0.4509 Q0.3703,0.4517,0.3703,0.4525 Q0.3703,0.4575,0.3373,0.4596 Q0.3133,0.4608,0.2988,0.4658 Q0.294,0.4674,0.2803,0.4674 Q0.2763,0.4674,0.2687,0.4672 T0.257,0.467 Q0.2402,0.467,0.2313,0.4687 Q0.2209,0.4708,0.2209,0.4737 Q0.2209,0.4741,0.2213,0.4749 T0.2229,0.4764 L0.2249,0.4774 T0.2265,0.4778 L0.2273,0.4782 Q0.2498,0.4803,0.2618,0.4803 Q0.2667,0.4803,0.2691,0.4799 Q0.2699,0.4795,0.2715,0.4795 Q0.2779,0.4795,0.3133,0.4873 Q0.3181,0.4886,0.3233,0.4892 T0.3386,0.4905 T0.3542,0.4915 T0.3655,0.4919 Q0.3703,0.4919,0.3779,0.4915 T0.3888,0.4911 T0.3952,0.4915 Q0.3976,0.4919,0.4008,0.4927 L0.4072,0.4944 T0.4149,0.4961 T0.4249,0.4975 T0.4369,0.4985 Q0.449,0.499,0.4554,0.5 T0.4651,0.5031 T0.4699,0.5085 Q0.4707,0.5093,0.4707,0.5097 Q0.4707,0.5114,0.4687,0.5124 T0.4631,0.5139 T0.4562,0.5145 T0.4478,0.5147 L0.4402,0.5147 Q0.4353,0.5151,0.4265,0.5174 T0.4096,0.5197 L0.4024,0.5197 Q0.4,0.5193,0.3968,0.5193 Q0.3896,0.5193,0.3831,0.5209 T0.3703,0.5253 T0.3598,0.5288 Q0.3558,0.5301,0.3321,0.5342 T0.302,0.5409 Q0.2876,0.5454,0.2337,0.55 Q0.2112,0.5521,0.2112,0.5558 Q0.2112,0.5574,0.2161,0.5612 Q0.2201,0.5637,0.2281,0.5637 Q0.2305,0.5637,0.2361,0.5633 T0.2442,0.5628 Q0.2474,0.5628,0.2498,0.5633 Q0.2635,0.5653,0.3133,0.5653 Q0.3261,0.5653,0.3301,0.5649 L0.3325,0.5649 Q0.3349,0.5649,0.3402,0.5657 T0.3494,0.5666 Q0.3542,0.5666,0.3598,0.5657 Q0.3663,0.5645,0.3695,0.5645 Q0.3735,0.5645,0.3775,0.5666 T0.3847,0.5695 Q0.3888,0.5707,0.3912,0.5709 T0.3968,0.5711 T0.4048,0.572 Q0.4096,0.5724,0.4165,0.5726 T0.4281,0.5734 T0.4378,0.5757 Q0.4418,0.5774,0.4522,0.5774 Q0.4562,0.5774,0.4655,0.5769 T0.4795,0.5765 Q0.4932,0.5765,0.4972,0.579 Q0.5028,0.5819,0.51,0.5819 Q0.5141,0.5819,0.5221,0.5809 T0.5341,0.5798 L0.5373,0.5798 Q0.5422,0.5798,0.557,0.5821 T0.5855,0.5844 L0.5936,0.5844 L0.5992,0.5844 T0.604,0.5848 T0.6076,0.5852 T0.6108,0.5859 T0.6137,0.5865 T0.6161,0.5873 T0.6181,0.5881 T0.6209,0.5896 L0.6233,0.591 Q0.6265,0.5927,0.6353,0.5927 L0.6402,0.5927 L0.645,0.5927 Q0.6723,0.5927,0.6948,0.6039 Q0.6988,0.606,0.7036,0.606 Q0.7076,0.606,0.7153,0.6045 T0.7261,0.6031 Q0.7309,0.6031,0.7454,0.6027 T0.7647,0.6022 Q0.7783,0.6022,0.808,0.608 L0.812,0.608 Q0.8169,0.608,0.8293,0.6062 T0.8474,0.6039 L0.8506,0.6039 Q0.857,0.6039,0.8655,0.6049 T0.8835,0.6074 T0.8972,0.6093 Q0.9036,0.6101,0.9213,0.6101 L0.9398,0.6101 L0.959,0.6101 L0.9767,0.6101 Q0.9976,0.6109,0.9976,0.6176 Q0.9976,0.6188,0.9968,0.6226 Q0.9952,0.6284,0.9671,0.6284 L0.959,0.6284 L0.9526,0.6284 Q0.9486,0.6284,0.9382,0.6302 T0.9205,0.6321 L0.9173,0.6321 Q0.9133,0.6317,0.9092,0.6317 Q0.9012,0.6317,0.8715,0.6346 Q0.8233,0.6387,0.7944,0.6421 Q0.7888,0.6429,0.7831,0.6429 Q0.7783,0.6429,0.7731,0.6425 T0.7663,0.6421 L0.7622,0.6421 Q0.7614,0.6425,0.7586,0.6425 T0.7474,0.6421 T0.7349,0.6416 Q0.7261,0.6416,0.7221,0.6429 Q0.7173,0.645,0.7032,0.646 T0.6711,0.6479 T0.6482,0.6491 Q0.645,0.6495,0.6418,0.6495 Q0.6353,0.6495,0.6273,0.6474 T0.6161,0.6454 Q0.612,0.6454,0.6088,0.6477 T0.6,0.6499 Q0.5912,0.6499,0.5494,0.6578 Q0.5422,0.6591,0.5373,0.6591 Q0.5341,0.6591,0.5281,0.6584 T0.5173,0.6578 L0.5124,0.6578 Q0.5004,0.6582,0.4747,0.6632 Q0.4675,0.6649,0.4398,0.6667 T0.3851,0.6703 T0.3566,0.6719 Q0.3534,0.6723,0.3406,0.6734 T0.3141,0.6757 T0.2948,0.6773 Q0.2908,0.6781,0.2819,0.6829 T0.2618,0.6877 Q0.2482,0.6877,0.2273,0.6958 T0.1992,0.7117 Q0.1976,0.7138,0.1976,0.7155 Q0.1976,0.7188,0.2032,0.7213 T0.2181,0.7258 T0.2289,0.7292 Q0.2337,0.7308,0.2378,0.7316 L0.2458,0.7333 T0.2522,0.7345 Q0.2747,0.7399,0.2835,0.7399 Q0.29,0.7399,0.3048,0.7408 T0.3253,0.7416 Q0.3277,0.7416,0.3297,0.7414 T0.3341,0.7412 L0.3598,0.7445 Q0.3639,0.7449,0.3663,0.7449 Q0.3695,0.7449,0.3751,0.7443 T0.3855,0.7437 L0.3904,0.7437 Q0.3984,0.7441,0.4141,0.7472 T0.4353,0.7507 Q0.4434,0.752,0.4434,0.7553 Q0.4434,0.7578,0.439,0.7605 T0.4297,0.764 Q0.4257,0.7648,0.4201,0.7655 T0.4076,0.7665 T0.3984,0.7673 Q0.3968,0.7673,0.3912,0.7677 T0.3815,0.7688 T0.3735,0.771 T0.3655,0.7727 Q0.3606,0.7727,0.3534,0.7708 T0.3458,0.769 L0.3446,0.769 Q0.3414,0.769,0.3297,0.7713 T0.3149,0.7735 Q0.3092,0.7735,0.296,0.7771 T0.2771,0.7831 Q0.2683,0.7872,0.2554,0.7885 Q0.253,0.7889,0.2325,0.7897 T0.2032,0.7914 Q0.2008,0.7918,0.198,0.792 T0.194,0.7924 T0.1912,0.7928 T0.1888,0.793 T0.1867,0.7934 T0.1851,0.7943 T0.1843,0.7953 T0.1835,0.7968 T0.1823,0.7984 T0.1815,0.8001 Q0.1815,0.8022,0.1847,0.8032 T0.1932,0.8053 T0.2008,0.8071 Q0.2072,0.8092,0.2265,0.8092 Q0.2361,0.8092,0.2586,0.8088 L0.2635,0.8088 Q0.2731,0.8088,0.2795,0.8098 T0.2928,0.8134 T0.302,0.8167 Q0.306,0.8179,0.3277,0.8183 T0.3542,0.8221 Q0.359,0.8246,0.3622,0.8246 Q0.3639,0.8246,0.3675,0.8237 T0.3751,0.8229 Q0.3968,0.8229,0.4048,0.8275 Q0.4088,0.8291,0.4161,0.8291 Q0.4225,0.8291,0.4353,0.8275 Q0.4369,0.827,0.4394,0.827 Q0.4474,0.827,0.4675,0.8345 Q0.4707,0.8358,0.4739,0.8358 Q0.4755,0.8358,0.4799,0.8353 T0.4876,0.8349 Q0.494,0.8349,0.5012,0.8362 L0.5076,0.837 T0.5133,0.8376 T0.5181,0.8382 T0.5221,0.8391 L0.5253,0.8399 T0.5277,0.8409 L0.5293,0.8422 T0.5301,0.8438 L0.5301,0.8461 Q0.5301,0.8499,0.5249,0.8511 T0.5112,0.8526 T0.5004,0.8532 Q0.4964,0.854,0.4851,0.8546 T0.4667,0.8565 Q0.4578,0.8577,0.441,0.8577 L0.4345,0.8577 L0.4289,0.8577 L0.4209,0.8577 Q0.4153,0.8582,0.4088,0.8586 T0.3968,0.8592 T0.3855,0.8594 T0.3755,0.8596 T0.3675,0.86 T0.3622,0.8602 Q0.3317,0.8635,0.3237,0.8635 Q0.2996,0.8635,0.2996,0.8681 Q0.2996,0.8698,0.3032,0.8733 T0.3068,0.8797 L0.3068,0.8822 Q0.3052,0.8859,0.2859,0.8859 Q0.2787,0.8859,0.2739,0.8851 L0.2699,0.8851 Q0.2659,0.8851,0.259,0.888 T0.2498,0.8913 Q0.2394,0.8946,0.2056,0.8971 Q0.2032,0.8971,0.1928,0.9002 T0.1703,0.9034 Q0.1526,0.9034,0.1353,0.9067 T0.1173,0.915 L0.1173,0.9158 Q0.1173,0.9166,0.1177,0.9175 T0.1189,0.9191 T0.1205,0.9204 T0.1229,0.9212 T0.1257,0.922 T0.1285,0.9226 T0.1309,0.9231 T0.1337,0.9235 T0.1365,0.9237 L0.1446,0.9237 T0.1554,0.9243 T0.1614,0.9268 T0.1659,0.9293 T0.1743,0.9303 T0.1823,0.9316 Q0.1863,0.932,0.1912,0.9332 T0.2028,0.9361 T0.2153,0.939 Q0.2233,0.9411,0.2297,0.9448 T0.2386,0.9494 Q0.2426,0.9506,0.2506,0.9506 Q0.253,0.9506,0.2594,0.9504 T0.2699,0.9502 Q0.2763,0.9502,0.2819,0.9511 Q0.2851,0.9511,0.2871,0.9529 T0.2892,0.9573 Q0.2892,0.9627,0.2811,0.9635 Q0.2434,0.9652,0.2337,0.9672 Q0.2257,0.9693,0.2241,0.9693 Q0.2217,0.9693,0.2096,0.9689 L0.2056,0.9689 Q0.1976,0.9689,0.1924,0.9699 T0.1787,0.9741 L0.1647,0.9793 Q0.1518,0.983,0.1285,0.983 L0.1261,0.983 L0.1237,0.983 Q0.1124,0.983,0.1076,0.9838 Q0.102,0.9851,0.0839,0.9896 T0.0602,0.9954 T0.0406,0.9971 T0.0153,0.9979 L0.0032,0.9979 Z",type:0}},"\uE0CA":{type:5,data:{d:"M0.9992,0.998 Q0.9992,1,0.9968,1 Q0.9521,1,0.9392,0.998 Q0.9335,0.997,0.9152,0.9921 T0.8913,0.9862 Q0.8873,0.9852,0.8783,0.9852 L0.8747,0.9852 L0.871,0.9852 Q0.8483,0.9852,0.8345,0.9812 Q0.8289,0.9792,0.8228,0.9773 T0.8135,0.9743 T0.8054,0.9723 T0.7948,0.9713 L0.7908,0.9713 L0.777,0.9713 T0.7656,0.9693 Q0.7616,0.9683,0.7498,0.9674 T0.7283,0.9664 L0.7186,0.9654 Q0.708,0.9644,0.708,0.9575 Q0.708,0.9545,0.7109,0.952 T0.7178,0.9486 L0.7315,0.9486 L0.7417,0.9486 L0.7502,0.9486 Q0.7575,0.9486,0.7599,0.9476 Q0.7616,0.9466,0.7685,0.9426 T0.7843,0.9377 Q0.7899,0.9357,0.8009,0.9332 T0.8175,0.9298 Q0.82,0.9288,0.8228,0.9283 T0.8277,0.9278 T0.8313,0.9273 T0.8341,0.9263 T0.8362,0.9258 Q0.8402,0.9219,0.854,0.9219 L0.8569,0.9219 L0.8597,0.9219 L0.8637,0.9219 Q0.88,0.9199,0.88,0.9159 L0.88,0.9149 Q0.88,0.913,0.8719,0.911 T0.852,0.9075 T0.8297,0.906 Q0.8224,0.906,0.8167,0.905 T0.8074,0.9031 T0.8001,0.9006 T0.794,0.8991 Q0.7599,0.8971,0.7494,0.8942 Q0.7461,0.8932,0.7397,0.8902 T0.7299,0.8872 L0.7267,0.8872 Q0.721,0.8882,0.7121,0.8882 Q0.6926,0.8882,0.691,0.8833 L0.691,0.8803 Q0.691,0.8773,0.6922,0.8749 T0.6955,0.8704 L0.6975,0.8684 Q0.6926,0.8655,0.6764,0.8655 Q0.6683,0.8655,0.6375,0.8625 Q0.6358,0.8625,0.629,0.862 T0.6148,0.8615 T0.5973,0.861 T0.5791,0.8605 L0.5718,0.8605 L0.5661,0.8605 L0.5596,0.8605 Q0.5426,0.8605,0.5337,0.8586 Q0.5264,0.8576,0.515,0.8571 T0.4996,0.8556 Q0.498,0.8556,0.4947,0.8551 T0.4882,0.8546 T0.4809,0.8541 T0.474,0.8531 T0.4692,0.8506 T0.4672,0.8467 Q0.4672,0.8437,0.468,0.8422 T0.4704,0.8398 T0.4736,0.8383 T0.4781,0.8368 T0.4838,0.8358 T0.4911,0.8353 T0.4988,0.8338 Q0.5069,0.8328,0.5142,0.8328 Q0.5174,0.8328,0.5215,0.8333 T0.5264,0.8338 Q0.5296,0.8338,0.532,0.8328 Q0.5523,0.8249,0.5604,0.8249 L0.5653,0.8249 Q0.5775,0.8269,0.5848,0.8269 Q0.5912,0.8269,0.5937,0.8259 Q0.6026,0.821,0.6253,0.821 Q0.6294,0.821,0.6334,0.822 T0.6383,0.8229 Q0.6399,0.8229,0.644,0.82 Q0.6504,0.817,0.6723,0.816 T0.6975,0.814 Q0.6999,0.814,0.7048,0.8121 T0.7129,0.8091 T0.7226,0.8071 T0.7372,0.8061 L0.7421,0.8061 Q0.7624,0.8071,0.7721,0.8071 Q0.7924,0.8071,0.7981,0.8051 Q0.8021,0.8032,0.807,0.8027 T0.8135,0.8017 T0.8151,0.8002 L0.8151,0.7992 Q0.8143,0.7962,0.8127,0.7957 T0.8045,0.7943 Q0.7997,0.7943,0.7964,0.7933 Q0.7883,0.7933,0.7676,0.7923 T0.7437,0.7903 Q0.7307,0.7893,0.7218,0.7844 Q0.7194,0.7834,0.7141,0.7819 L0.7036,0.7789 T0.693,0.7765 T0.6853,0.7755 Q0.6805,0.7755,0.6691,0.773 T0.6553,0.7705 Q0.6545,0.7705,0.6545,0.7715 Q0.6537,0.7715,0.65,0.7725 T0.6423,0.774 T0.635,0.7745 Q0.6294,0.7745,0.6257,0.773 T0.618,0.771 T0.6083,0.77 T0.601,0.7695 Q0.5985,0.7695,0.5921,0.769 T0.5795,0.7676 T0.5702,0.7666 Q0.5653,0.7656,0.5596,0.7616 T0.5539,0.7547 Q0.5539,0.7498,0.5645,0.7488 Q0.5693,0.7478,0.5852,0.7448 T0.6099,0.7418 L0.6148,0.7418 Q0.6196,0.7418,0.6253,0.7423 T0.6338,0.7428 L0.6399,0.7428 Q0.6431,0.7418,0.6488,0.7409 T0.6586,0.7394 T0.6659,0.7389 L0.6707,0.7389 Q0.6723,0.7399,0.6748,0.7399 Q0.6796,0.7399,0.6946,0.7389 T0.7161,0.7379 Q0.7251,0.7379,0.7478,0.7319 Q0.7494,0.7319,0.7539,0.7315 T0.762,0.73 T0.7697,0.727 T0.7818,0.723 T0.7948,0.7196 T0.7997,0.7151 Q0.7997,0.7141,0.7989,0.7132 Q0.7924,0.7062,0.7717,0.6978 T0.738,0.6894 Q0.7267,0.6894,0.717,0.6845 T0.7048,0.6795 Q0.6975,0.6785,0.6711,0.6766 T0.6431,0.6746 Q0.6415,0.6736,0.6144,0.6721 T0.5596,0.6686 T0.5247,0.6657 Q0.4996,0.6607,0.4874,0.6597 L0.4834,0.6597 Q0.4785,0.6597,0.4724,0.6607 T0.4631,0.6617 Q0.4574,0.6617,0.4501,0.6597 Q0.4088,0.6518,0.4006,0.6518 Q0.3933,0.6518,0.3901,0.6499 T0.3844,0.6479 T0.3739,0.6499 T0.3585,0.6518 L0.352,0.6518 Q0.3471,0.6508,0.335,0.6503 T0.3122,0.6494 T0.2912,0.6479 T0.2766,0.6449 Q0.2733,0.6439,0.2668,0.6439 Q0.2628,0.6439,0.2543,0.6444 T0.2425,0.6449 T0.2368,0.6439 L0.2344,0.6439 Q0.2336,0.6439,0.2283,0.6444 T0.2182,0.6449 Q0.2117,0.6449,0.2052,0.6439 Q0.1768,0.6409,0.1281,0.637 Q0.0989,0.634,0.0916,0.634 L0.0835,0.634 L0.0795,0.634 Q0.0722,0.634,0.0616,0.632 T0.0479,0.6301 Q0.0462,0.6301,0.0406,0.6306 T0.0308,0.6311 Q0.0032,0.6311,0.0008,0.6231 Q0,0.6202,0,0.6182 Q0,0.6083,0.0227,0.6083 Q0.03,0.6073,0.0422,0.6073 Q0.0487,0.6073,0.0608,0.6078 T0.0787,0.6083 Q0.0973,0.6083,0.103,0.6073 Q0.1062,0.6063,0.1131,0.6053 T0.1253,0.6039 T0.1375,0.6024 T0.1492,0.6014 L0.1533,0.6014 Q0.1598,0.6024,0.1723,0.6044 T0.189,0.6063 Q0.1906,0.6063,0.1922,0.6053 Q0.2214,0.6004,0.236,0.6004 Q0.2409,0.6004,0.2551,0.6009 T0.2741,0.6014 Q0.2782,0.6014,0.2859,0.6024 T0.2968,0.6034 Q0.3009,0.6034,0.3041,0.6024 Q0.3277,0.5905,0.356,0.5905 L0.3613,0.5905 L0.3666,0.5905 Q0.3731,0.5905,0.3755,0.5895 Q0.382,0.5856,0.3844,0.5846 T0.3938,0.5831 T0.4144,0.5826 T0.4428,0.5801 T0.4631,0.5776 L0.4663,0.5776 Q0.4704,0.5776,0.4785,0.5786 T0.4899,0.5796 Q0.4964,0.5796,0.5012,0.5767 Q0.5069,0.5737,0.5223,0.5737 Q0.5272,0.5737,0.5369,0.5742 T0.5499,0.5747 Q0.558,0.5747,0.5612,0.5737 Q0.5661,0.5717,0.5714,0.5712 T0.5835,0.5702 T0.5945,0.5692 T0.6018,0.5687 L0.6062,0.5687 T0.6095,0.5682 T0.6148,0.5678 Q0.6172,0.5668,0.6196,0.5653 T0.6245,0.5633 T0.631,0.5628 T0.6407,0.5638 T0.6504,0.5648 T0.6594,0.5638 T0.6675,0.5628 L0.6707,0.5628 L0.6861,0.5628 Q0.7364,0.5628,0.7494,0.5608 L0.7567,0.5608 L0.7652,0.5608 L0.7729,0.5608 Q0.7794,0.5608,0.7818,0.5589 Q0.7851,0.5569,0.7859,0.5559 Q0.7835,0.5539,0.7656,0.5519 Q0.7121,0.547,0.6975,0.543 Q0.6934,0.541,0.6853,0.5396 L0.6691,0.5366 T0.6525,0.5336 T0.6391,0.5312 Q0.6367,0.5302,0.6245,0.5257 T0.6034,0.5213 L0.5977,0.5213 Q0.5945,0.5223,0.5904,0.5223 Q0.5839,0.5223,0.5787,0.5208 T0.5685,0.5183 T0.5596,0.5173 Q0.558,0.5173,0.5552,0.5168 T0.5495,0.5163 L0.5434,0.5163 T0.5373,0.5158 T0.532,0.5148 T0.5284,0.5129 T0.5272,0.5104 L0.5272,0.5074 Q0.5304,0.5015,0.5377,0.499 T0.5629,0.4965 Q0.5734,0.4955,0.5811,0.4946 T0.5953,0.4916 T0.605,0.4896 Q0.6083,0.4886,0.6123,0.4886 Q0.6148,0.4886,0.6225,0.4891 T0.635,0.4896 L0.6456,0.4896 Q0.6837,0.4857,0.6861,0.4857 Q0.7226,0.4768,0.7283,0.4768 Q0.7307,0.4768,0.732,0.4773 T0.7372,0.4777 Q0.7502,0.4777,0.7721,0.4758 Q0.7753,0.4748,0.777,0.4738 Q0.7745,0.4718,0.768,0.4708 Q0.7599,0.4688,0.7445,0.4688 Q0.7405,0.4688,0.7324,0.4693 T0.721,0.4698 Q0.7056,0.4698,0.6999,0.4679 Q0.6861,0.4629,0.6626,0.4619 Q0.6586,0.4609,0.6533,0.4604 T0.6419,0.459 T0.6314,0.456 T0.6269,0.452 Q0.6269,0.451,0.6302,0.4491 Q0.6358,0.4451,0.6525,0.4416 T0.6788,0.4382 L0.6845,0.4382 L0.6902,0.4382 Q0.7064,0.4382,0.7145,0.4362 Q0.7202,0.4342,0.7307,0.4342 L0.7356,0.4342 L0.7409,0.4342 L0.7453,0.4342 Q0.7543,0.4342,0.7607,0.4332 Q0.7778,0.4313,0.7847,0.4288 T0.7932,0.4248 T0.7972,0.4169 T0.8045,0.4045 L0.8094,0.3986 T0.8135,0.3942 L0.8167,0.3912 T0.8191,0.3887 T0.8204,0.3867 T0.8208,0.3848 Q0.8208,0.3818,0.8167,0.3739 Q0.8127,0.3689,0.8054,0.3635 T0.7924,0.3556 T0.7778,0.3497 T0.768,0.3462 Q0.764,0.3442,0.7478,0.3427 T0.7267,0.3383 Q0.7234,0.3363,0.7044,0.3348 T0.6805,0.3323 L0.6764,0.3323 T0.6679,0.3328 T0.6586,0.3333 Q0.6521,0.3333,0.648,0.3314 Q0.6391,0.3264,0.6294,0.3264 Q0.6229,0.3264,0.6156,0.3294 Q0.6115,0.3304,0.6042,0.3304 Q0.5977,0.3304,0.5888,0.3289 T0.5738,0.3264 T0.5584,0.3225 L0.5491,0.3195 Q0.5418,0.3185,0.5162,0.3155 T0.4895,0.3121 T0.4826,0.3116 T0.4716,0.3111 T0.4607,0.3096 T0.4513,0.3066 T0.4477,0.3017 Q0.4477,0.2997,0.4485,0.2977 Q0.4526,0.2898,0.4611,0.2878 T0.4842,0.2859 Q0.4882,0.2859,0.4943,0.2864 T0.5028,0.2868 Q0.5118,0.2868,0.5215,0.2829 Q0.5401,0.276,0.5734,0.276 Q0.5831,0.276,0.5872,0.277 L0.5904,0.277 T0.5949,0.2745 T0.6034,0.272 Q0.631,0.269,0.6407,0.2661 Q0.6448,0.2641,0.6488,0.2641 T0.6573,0.2656 T0.6634,0.2671 Q0.6659,0.2671,0.6675,0.2651 Q0.6723,0.2601,0.6886,0.2552 T0.7137,0.2502 L0.7153,0.2502 L0.7178,0.2502 Q0.7218,0.2502,0.7279,0.2493 T0.7417,0.2468 T0.7526,0.2453 Q0.8208,0.2354,0.8281,0.2354 Q0.8337,0.2354,0.8394,0.2344 T0.8496,0.2315 T0.854,0.2265 Q0.854,0.2216,0.8443,0.2176 Q0.8354,0.2127,0.8216,0.2102 T0.7972,0.2072 T0.777,0.2067 T0.7648,0.2057 Q0.7632,0.2047,0.7498,0.2038 T0.7202,0.2018 T0.6983,0.1998 Q0.6918,0.1998,0.676,0.1949 T0.6537,0.1899 Q0.6496,0.1899,0.6472,0.1919 Q0.6342,0.1968,0.6123,0.1968 Q0.5961,0.1968,0.5912,0.1929 Q0.5904,0.1919,0.5892,0.1919 T0.5823,0.1944 T0.5722,0.1968 T0.5637,0.1949 Q0.5556,0.1909,0.5158,0.1869 Q0.5109,0.1869,0.5032,0.1835 T0.4915,0.18 Q0.4907,0.18,0.4891,0.181 Q0.4793,0.182,0.455,0.182 Q0.4177,0.182,0.4015,0.1771 Q0.3901,0.1741,0.3779,0.1741 Q0.3747,0.1741,0.369,0.1746 T0.3609,0.1751 Q0.3569,0.1751,0.3536,0.1741 Q0.3512,0.1741,0.3459,0.1736 T0.3358,0.1726 T0.3256,0.1716 T0.3167,0.1696 T0.3106,0.1662 Q0.3082,0.1632,0.3082,0.1592 Q0.3082,0.1543,0.3114,0.1508 T0.3204,0.1474 L0.3236,0.1474 Q0.3301,0.1494,0.3333,0.1494 Q0.339,0.1494,0.3496,0.1454 Q0.3674,0.1385,0.3869,0.1385 Q0.395,0.1385,0.4011,0.137 T0.4116,0.1355 T0.4197,0.1375 T0.425,0.1395 Q0.429,0.1395,0.4355,0.1365 Q0.4631,0.1266,0.4882,0.1266 Q0.5126,0.1266,0.5337,0.1246 Q0.5369,0.1246,0.5458,0.1207 T0.5661,0.1167 L0.5693,0.1167 Q0.5791,0.1177,0.5852,0.1187 T0.5921,0.1197 Q0.5945,0.1197,0.6002,0.1167 Q0.6026,0.1147,0.605,0.1123 T0.6091,0.1093 T0.6156,0.1088 T0.6318,0.1098 Q0.6456,0.1108,0.6569,0.1137 T0.6707,0.1167 Q0.674,0.1167,0.6772,0.1147 Q0.6821,0.1108,0.6946,0.1083 T0.721,0.1044 T0.7453,0.1029 L0.7526,0.1029 Q0.7583,0.1039,0.7652,0.1053 T0.7753,0.1068 Q0.7778,0.1068,0.7802,0.1058 Q0.7981,0.1019,0.8143,0.1019 Q0.8175,0.1019,0.8439,0.0964 T0.8735,0.091 Q0.8759,0.091,0.8836,0.089 T0.8938,0.0851 Q0.8929,0.0841,0.8905,0.0841 Q0.8832,0.0821,0.8735,0.0821 Q0.8694,0.0821,0.8629,0.0826 T0.854,0.0831 Q0.8508,0.0831,0.8491,0.0821 Q0.8475,0.0821,0.8171,0.0811 T0.7802,0.0781 T0.7571,0.0737 T0.7332,0.0712 Q0.7299,0.0712,0.7247,0.0717 T0.7161,0.0722 L0.7064,0.0722 Q0.7032,0.0712,0.7003,0.0707 T0.6959,0.0692 T0.693,0.0678 T0.6914,0.0663 T0.6902,0.0648 T0.689,0.0643 L0.6869,0.0643 L0.6845,0.0643 L0.6821,0.0643 Q0.6553,0.0643,0.6553,0.0554 T0.6796,0.0465 L0.6813,0.0465 L0.6845,0.0465 Q0.6918,0.0425,0.717,0.0376 Q0.7226,0.0366,0.7267,0.0356 T0.7324,0.0336 T0.7376,0.0317 T0.747,0.0297 Q0.7518,0.0287,0.7551,0.0287 T0.762,0.0297 T0.768,0.0307 L0.7729,0.0307 Q0.7802,0.0277,0.7997,0.0267 T0.8224,0.0237 Q0.8273,0.0227,0.8341,0.0223 T0.8475,0.0193 T0.8654,0.0143 T0.8873,0.0079 Q0.9002,0.003,0.9331,0.004 T0.97,0.004 Q0.9838,0,0.9976,0.001 Q1,0.001,1,0.003 Z M0.9935,0.996 L0.9951,0.0049 Q0.9822,0.0049,0.9716,0.0079 Q0.9659,0.0099,0.9327,0.0089 T0.8897,0.0119 Q0.8775,0.0158,0.8666,0.0183 T0.85,0.0237 Q0.8418,0.0267,0.8341,0.0272 T0.8248,0.0277 Q0.82,0.0297,0.8001,0.0312 T0.7745,0.0346 Q0.7697,0.0356,0.7664,0.0356 Q0.764,0.0356,0.7603,0.0341 T0.7534,0.0326 Q0.751,0.0326,0.7478,0.0336 Q0.7429,0.0346,0.7397,0.0356 T0.7344,0.0376 T0.7283,0.0401 T0.7178,0.0425 Q0.6926,0.0465,0.6878,0.0495 Q0.6845,0.0514,0.6764,0.0514 L0.6723,0.0514 L0.6691,0.0514 Q0.6602,0.0514,0.6602,0.0554 Q0.6602,0.0574,0.6659,0.0579 T0.6796,0.0589 T0.691,0.0603 T0.6967,0.0643 T0.7072,0.0673 Q0.7121,0.0682,0.7153,0.0682 T0.7238,0.0678 T0.7332,0.0673 Q0.7413,0.0673,0.7579,0.0692 T0.7818,0.0742 Q0.7867,0.0752,0.8171,0.0762 T0.8508,0.0781 L0.852,0.0781 T0.8601,0.0776 T0.8719,0.0772 Q0.8832,0.0772,0.8921,0.0791 Q0.8994,0.0811,0.8994,0.0841 T0.8933,0.0895 T0.8812,0.094 T0.8735,0.0959 T0.8451,0.1014 T0.8143,0.1068 Q0.7989,0.1068,0.7818,0.1098 Q0.7778,0.1108,0.7745,0.1108 T0.768,0.1103 T0.7603,0.1088 T0.7518,0.1068 L0.7461,0.1068 Q0.7315,0.1068,0.7088,0.1103 T0.6805,0.1177 Q0.6756,0.1207,0.6699,0.1207 Q0.6667,0.1207,0.6545,0.1177 T0.631,0.1147 Q0.6269,0.1137,0.6225,0.1133 T0.616,0.1128 L0.6123,0.1128 T0.6103,0.1133 L0.6083,0.1157 T0.6034,0.1197 Q0.5961,0.1246,0.5921,0.1246 Q0.5904,0.1246,0.5835,0.1231 T0.5693,0.1217 L0.5661,0.1217 Q0.558,0.1217,0.5535,0.1231 T0.5446,0.1266 T0.5345,0.1296 Q0.5126,0.1316,0.4882,0.1316 T0.438,0.1405 Q0.4298,0.1434,0.425,0.1434 Q0.4209,0.1434,0.4173,0.1419 T0.4112,0.1405 T0.4027,0.1419 T0.3869,0.1434 Q0.3682,0.1434,0.3512,0.1494 Q0.3398,0.1533,0.3333,0.1533 Q0.3285,0.1533,0.3228,0.1513 L0.3212,0.1513 Q0.3179,0.1513,0.3155,0.1538 T0.3131,0.1592 Q0.3131,0.1612,0.3147,0.1632 Q0.3171,0.1652,0.3232,0.1667 T0.3406,0.1691 T0.3544,0.1701 L0.3601,0.1701 L0.3678,0.1701 L0.3771,0.1701 Q0.3909,0.1701,0.4031,0.1731 Q0.4177,0.178,0.4558,0.178 Q0.4793,0.178,0.4882,0.1761 L0.4915,0.1761 Q0.498,0.1761,0.5061,0.179 T0.5158,0.183 Q0.5166,0.183,0.5231,0.1835 T0.5341,0.1845 T0.545,0.1855 T0.5572,0.1874 T0.5661,0.1909 Q0.5677,0.1919,0.5702,0.1919 T0.5791,0.1894 T0.588,0.1869 Q0.5921,0.1869,0.5945,0.1899 Q0.5977,0.1919,0.6123,0.1919 Q0.6334,0.1919,0.6448,0.1879 Q0.6488,0.186,0.6537,0.186 Q0.6626,0.186,0.6784,0.1909 T0.6983,0.1958 Q0.704,0.1968,0.7206,0.1973 T0.7506,0.1993 T0.7689,0.2018 L0.7705,0.2018 L0.7741,0.2018 L0.7786,0.2018 Q0.8248,0.2018,0.8475,0.2136 Q0.8597,0.2196,0.8597,0.2255 T0.8483,0.2359 T0.8281,0.2404 Q0.8208,0.2404,0.7534,0.2493 Q0.7502,0.2502,0.7425,0.2512 T0.7287,0.2532 T0.7178,0.2542 L0.7153,0.2542 L0.7137,0.2542 Q0.7064,0.2542,0.6906,0.2591 T0.6715,0.2681 T0.6626,0.272 Q0.6594,0.272,0.6541,0.2705 T0.6464,0.269 T0.6431,0.27 Q0.6318,0.273,0.6034,0.276 Q0.6018,0.276,0.5998,0.2774 T0.5957,0.2799 T0.5904,0.2809 L0.5856,0.2809 Q0.5823,0.2799,0.575,0.2799 Q0.541,0.2799,0.5239,0.2868 Q0.5134,0.2908,0.5028,0.2908 Q0.4996,0.2908,0.4935,0.2903 T0.4842,0.2898 Q0.4704,0.2898,0.4635,0.2918 T0.4534,0.2997 L0.4534,0.3007 Q0.4534,0.3037,0.459,0.3046 T0.4753,0.3066 T0.4899,0.3076 Q0.4907,0.3076,0.5166,0.3106 T0.5507,0.3155 Q0.5921,0.3264,0.605,0.3264 Q0.6107,0.3264,0.6131,0.3254 Q0.6212,0.3225,0.6294,0.3225 Q0.6407,0.3225,0.6513,0.3274 Q0.6529,0.3294,0.6569,0.3294 Q0.6594,0.3294,0.665,0.3284 T0.6748,0.3274 T0.6821,0.3284 Q0.6861,0.3294,0.6967,0.3299 T0.7165,0.3314 T0.7299,0.3343 Q0.7324,0.3363,0.7486,0.3383 T0.7697,0.3422 Q0.7713,0.3432,0.7774,0.3452 T0.7887,0.3492 T0.8005,0.3546 T0.8127,0.3625 T0.8208,0.3719 Q0.8256,0.3798,0.8256,0.3848 Q0.8256,0.3877,0.8236,0.3902 T0.8171,0.3976 T0.809,0.4075 T0.8021,0.4189 T0.7968,0.4273 T0.7867,0.4322 T0.7616,0.4382 Q0.7543,0.4392,0.7437,0.4392 L0.7364,0.4392 L0.7299,0.4392 Q0.7202,0.4392,0.7161,0.4402 Q0.7072,0.4431,0.691,0.4431 L0.6845,0.4431 L0.6788,0.4431 Q0.6707,0.4431,0.6545,0.4461 T0.6334,0.452 Q0.6399,0.456,0.6626,0.457 Q0.6878,0.458,0.7024,0.4639 Q0.7064,0.4649,0.7186,0.4649 L0.7295,0.4649 L0.7413,0.4649 Q0.7591,0.4649,0.7697,0.4669 Q0.7818,0.4688,0.7818,0.4728 Q0.7818,0.4748,0.7798,0.4763 T0.7753,0.4787 L0.7737,0.4797 L0.7729,0.4797 Q0.7494,0.4827,0.7388,0.4827 Q0.7332,0.4827,0.7307,0.4817 L0.7291,0.4817 Q0.7226,0.4817,0.6878,0.4896 Q0.6829,0.4906,0.6772,0.4911 T0.6618,0.4921 T0.646,0.4931 T0.6342,0.4936 T0.6212,0.4931 T0.6115,0.4926 Q0.6083,0.4926,0.6058,0.4931 T0.5969,0.4955 T0.5819,0.499 T0.5629,0.5005 Q0.5572,0.5005,0.5527,0.501 T0.545,0.502 T0.5397,0.503 T0.5361,0.5045 T0.5337,0.5064 T0.5328,0.5094 L0.532,0.5094 Q0.532,0.5104,0.5337,0.5109 T0.5381,0.5114 T0.5438,0.5119 T0.5515,0.5124 L0.5604,0.5124 Q0.5661,0.5134,0.5746,0.5153 T0.5912,0.5173 L0.5977,0.5173 L0.6026,0.5173 Q0.6139,0.5173,0.6273,0.5218 T0.6407,0.5272 Q0.6448,0.5282,0.6683,0.5321 T0.6991,0.5381 Q0.7137,0.543,0.7664,0.548 Q0.7908,0.55,0.7908,0.5559 Q0.7908,0.5589,0.7859,0.5628 Q0.781,0.5658,0.7713,0.5658 Q0.7689,0.5658,0.7628,0.5653 T0.7543,0.5648 T0.7502,0.5658 Q0.7364,0.5678,0.6878,0.5678 Q0.674,0.5678,0.6699,0.5668 L0.6683,0.5668 Q0.6659,0.5668,0.6606,0.5678 T0.6513,0.5687 Q0.6464,0.5687,0.6391,0.5678 Q0.6334,0.5668,0.6302,0.5668 T0.6241,0.5687 T0.6164,0.5717 Q0.6123,0.5727,0.6099,0.5727 L0.6062,0.5727 T0.6022,0.5732 T0.5953,0.5737 Q0.5904,0.5747,0.5839,0.5747 T0.5726,0.5752 T0.5629,0.5776 T0.5458,0.5796 Q0.5418,0.5796,0.5324,0.5791 T0.5191,0.5786 Q0.5077,0.5786,0.5045,0.5806 Q0.498,0.5836,0.4899,0.5836 Q0.4858,0.5836,0.4777,0.5826 T0.4655,0.5816 L0.4631,0.5816 Q0.4582,0.5816,0.4436,0.5841 T0.4144,0.5865 L0.4051,0.5865 L0.3986,0.5865 T0.3933,0.587 T0.3897,0.5875 T0.3869,0.5885 T0.3844,0.5895 T0.3812,0.591 T0.3779,0.5925 Q0.3739,0.5955,0.3633,0.5955 L0.3585,0.5955 L0.3536,0.5955 Q0.3277,0.5955,0.3066,0.6053 Q0.3017,0.6083,0.296,0.6083 Q0.2912,0.6083,0.2839,0.6068 T0.2741,0.6053 Q0.2693,0.6053,0.2547,0.6048 T0.236,0.6044 Q0.2222,0.6044,0.193,0.6103 L0.1882,0.6103 Q0.1825,0.6103,0.1703,0.6083 T0.1525,0.6063 L0.15,0.6063 Q0.1436,0.6063,0.135,0.6073 T0.1168,0.6098 T0.1038,0.6113 Q0.0973,0.6123,0.0787,0.6123 L0.06,0.6123 L0.0414,0.6123 L0.0235,0.6123 Q0.0049,0.6133,0.0049,0.6172 L0.0057,0.6222 Q0.0073,0.6261,0.043,0.6261 L0.0454,0.6261 L0.0479,0.6261 Q0.0527,0.6261,0.0633,0.6281 T0.0803,0.6301 L0.0835,0.6301 L0.0916,0.6301 Q0.0998,0.6301,0.129,0.632 Q0.1768,0.636,0.206,0.64 Q0.2117,0.6409,0.2165,0.6409 Q0.2206,0.6409,0.2263,0.6405 T0.2336,0.64 L0.2384,0.64 L0.2409,0.64 T0.251,0.6395 T0.2644,0.639 Q0.2741,0.639,0.279,0.6409 Q0.283,0.6429,0.2972,0.6439 T0.3293,0.6459 T0.3528,0.6469 L0.3577,0.6469 Q0.3642,0.6469,0.3723,0.6449 T0.3844,0.6429 Q0.3901,0.6429,0.3933,0.6454 T0.4006,0.6479 Q0.4088,0.6479,0.4517,0.6558 Q0.4582,0.6568,0.4623,0.6568 Q0.4655,0.6568,0.4716,0.6563 T0.4826,0.6558 L0.4882,0.6558 Q0.5004,0.6558,0.5255,0.6607 Q0.5328,0.6627,0.5604,0.6647 T0.6152,0.6682 T0.644,0.6696 T0.6719,0.6721 T0.7056,0.6756 Q0.7113,0.6756,0.7198,0.6805 T0.738,0.6855 Q0.7453,0.6855,0.7551,0.6879 T0.7741,0.6939 T0.7912,0.7018 T0.8029,0.7102 Q0.8054,0.7132,0.8054,0.7151 Q0.8054,0.7191,0.8009,0.7216 T0.7908,0.7255 T0.779,0.7285 T0.7721,0.731 Q0.7672,0.7329,0.7632,0.7339 T0.7551,0.7354 T0.7486,0.7369 Q0.7478,0.7369,0.7445,0.7379 T0.7388,0.7394 T0.7324,0.7404 L0.7242,0.7413 T0.7161,0.7418 Q0.7105,0.7418,0.6955,0.7428 T0.6748,0.7438 L0.6699,0.7438 L0.6667,0.7438 Q0.6659,0.7438,0.6407,0.7468 Q0.6367,0.7478,0.6334,0.7478 T0.6245,0.7468 T0.6148,0.7458 L0.6099,0.7458 Q0.6018,0.7468,0.5864,0.7498 T0.5653,0.7532 T0.5596,0.7557 Q0.5596,0.7596,0.571,0.7616 Q0.5742,0.7626,0.5803,0.7631 T0.5929,0.7641 T0.6018,0.7656 L0.6087,0.7656 T0.6192,0.7666 T0.6277,0.7695 Q0.631,0.7705,0.6342,0.7705 Q0.6383,0.7705,0.6452,0.7685 T0.6537,0.7666 L0.6553,0.7666 Q0.6602,0.7666,0.6711,0.769 T0.6853,0.7715 Q0.691,0.7715,0.7048,0.775 T0.7242,0.7814 Q0.7324,0.7854,0.7445,0.7864 Q0.7478,0.7864,0.768,0.7873 T0.7972,0.7893 Q0.7989,0.7893,0.8017,0.7898 T0.8054,0.7903 L0.8082,0.7903 T0.811,0.7908 T0.8131,0.7913 T0.8151,0.7918 L0.8167,0.7928 T0.8179,0.7943 T0.8191,0.7957 T0.8204,0.7977 T0.8208,0.8002 Q0.8208,0.8032,0.8171,0.8051 T0.8074,0.8081 T0.7997,0.8091 Q0.7932,0.8121,0.7737,0.8121 Q0.7664,0.8121,0.7413,0.8111 L0.7364,0.8111 Q0.7275,0.8111,0.7214,0.8121 T0.7084,0.8155 T0.6991,0.819 Q0.6934,0.82,0.6723,0.8205 T0.6472,0.8239 Q0.6423,0.8269,0.6375,0.8269 Q0.6358,0.8269,0.6338,0.8264 T0.6294,0.8254 T0.6253,0.8249 Q0.6042,0.8249,0.5961,0.8289 Q0.5921,0.8318,0.5839,0.8318 Q0.5783,0.8318,0.5645,0.8299 L0.5604,0.8299 Q0.5531,0.8299,0.5337,0.8368 Q0.5296,0.8378,0.5255,0.8378 Q0.5239,0.8378,0.5195,0.8373 T0.5118,0.8368 Q0.5061,0.8368,0.4996,0.8388 Q0.4947,0.8388,0.4882,0.8398 Q0.4777,0.8417,0.4753,0.8427 T0.4728,0.8467 Q0.4728,0.8487,0.4769,0.8492 T0.4886,0.8501 T0.5004,0.8516 Q0.5036,0.8516,0.515,0.8521 T0.5345,0.8546 Q0.5426,0.8556,0.5596,0.8556 L0.5649,0.8556 L0.5702,0.8556 L0.5791,0.8556 Q0.5921,0.8566,0.6135,0.8571 T0.6383,0.8586 Q0.6683,0.8615,0.6764,0.8615 L0.6776,0.8615 L0.6788,0.8615 Q0.7032,0.8615,0.7032,0.8684 Q0.7032,0.8714,0.6995,0.8749 T0.6959,0.8803 L0.6959,0.8813 Q0.6991,0.8843,0.7153,0.8843 Q0.7218,0.8843,0.7259,0.8833 L0.7299,0.8833 Q0.7324,0.8833,0.7348,0.8838 T0.7393,0.8848 T0.7433,0.8863 L0.7474,0.8882 T0.751,0.8892 Q0.7616,0.8922,0.7948,0.8952 Q0.7981,0.8952,0.8082,0.8981 T0.8297,0.9011 T0.8528,0.9026 T0.8747,0.907 T0.8856,0.9149 L0.8856,0.9159 Q0.8856,0.9179,0.8848,0.9189 T0.8828,0.9209 T0.88,0.9228 T0.8767,0.9243 T0.8731,0.9248 T0.8698,0.9253 T0.8666,0.9258 L0.8646,0.9258 Q0.8621,0.9258,0.8577,0.9263 T0.85,0.9268 T0.8439,0.9273 T0.8402,0.9278 Q0.8386,0.9298,0.8358,0.9308 T0.8305,0.9322 T0.8244,0.9332 T0.8183,0.9337 Q0.8135,0.9347,0.8025,0.9372 T0.7859,0.9416 Q0.7778,0.9436,0.7717,0.9471 T0.7624,0.9515 Q0.7575,0.9535,0.7486,0.9535 Q0.7461,0.9535,0.7397,0.953 T0.7291,0.9525 Q0.7234,0.9525,0.7186,0.953 T0.7137,0.957 T0.7194,0.9614 Q0.7567,0.9634,0.7664,0.9654 Q0.7745,0.9674,0.7762,0.9674 L0.7908,0.9674 Q0.7924,0.9664,0.7948,0.9664 Q0.8029,0.9664,0.8082,0.9679 T0.8224,0.9723 T0.8362,0.9773 Q0.8483,0.9812,0.871,0.9812 L0.8751,0.9812 Q0.8873,0.9812,0.8929,0.9822 T0.9165,0.9876 T0.9408,0.9931 Q0.9513,0.996,0.9935,0.996 Z M0.9968,0.998 L0.9976,0.003 Q0.9838,0.002,0.9708,0.0059 Q0.9659,0.0069,0.9327,0.0059 T0.8881,0.0099 Q0.8767,0.0138,0.8658,0.0163 T0.8491,0.0218 Q0.8418,0.0247,0.8341,0.0247 T0.8232,0.0257 Q0.8191,0.0277,0.7997,0.0292 T0.7737,0.0326 Q0.7697,0.0336,0.7672,0.0336 T0.7612,0.0321 T0.7543,0.0307 T0.747,0.0317 Q0.7397,0.0326,0.7336,0.0356 T0.717,0.0406 Q0.6918,0.0445,0.6861,0.0475 Q0.6845,0.0485,0.6772,0.0485 T0.6638,0.05 T0.6577,0.0549 T0.6642,0.0598 T0.6796,0.0613 T0.6902,0.0623 Q0.691,0.0623,0.6946,0.0658 T0.7064,0.0702 L0.7161,0.0702 Q0.7194,0.0702,0.7242,0.0697 T0.7332,0.0692 Q0.7413,0.0692,0.7579,0.0717 T0.781,0.0762 Q0.7867,0.0781,0.8171,0.0786 T0.85,0.0801 L0.8532,0.0801 Q0.8548,0.0801,0.8613,0.0796 T0.8727,0.0791 Q0.8832,0.0791,0.8913,0.0811 Q0.897,0.0831,0.897,0.0851 Q0.897,0.088,0.8873,0.0905 T0.8735,0.093 Q0.871,0.093,0.8447,0.0984 T0.8143,0.1039 Q0.7981,0.1039,0.781,0.1078 Q0.7778,0.1088,0.7745,0.1088 T0.7644,0.1073 T0.7518,0.1048 L0.7453,0.1048 Q0.7299,0.1048,0.7076,0.1083 T0.6788,0.1157 Q0.6748,0.1187,0.6699,0.1187 Q0.6675,0.1187,0.6557,0.1157 T0.6318,0.1118 Q0.6164,0.1108,0.6135,0.1108 T0.6095,0.1113 T0.6067,0.1142 T0.6018,0.1187 Q0.5953,0.1227,0.5921,0.1227 Q0.5904,0.1227,0.5839,0.1212 T0.5693,0.1187 L0.5661,0.1187 Q0.558,0.1187,0.5527,0.1207 T0.5434,0.1246 T0.5337,0.1266 Q0.5126,0.1296,0.4882,0.1296 T0.4371,0.1385 Q0.4298,0.1414,0.425,0.1414 Q0.4225,0.1414,0.4189,0.1395 T0.4112,0.1375 Q0.4079,0.1375,0.4019,0.1395 T0.3869,0.1414 Q0.3682,0.1414,0.3504,0.1474 Q0.3398,0.1513,0.3333,0.1513 Q0.3293,0.1513,0.3228,0.1494 L0.3204,0.1494 Q0.3179,0.1494,0.3155,0.1508 T0.3118,0.1543 T0.3106,0.1592 T0.3127,0.1642 T0.32,0.1677 T0.3305,0.1701 T0.3435,0.1716 T0.3544,0.1721 Q0.3569,0.1731,0.3601,0.1731 Q0.3625,0.1731,0.3686,0.1726 T0.3779,0.1721 Q0.3901,0.1721,0.4023,0.1751 Q0.4177,0.18,0.455,0.18 Q0.4793,0.18,0.4882,0.178 L0.4915,0.178 Q0.4964,0.178,0.5045,0.1815 T0.5158,0.185 Q0.5564,0.1889,0.5645,0.1929 Q0.5677,0.1939,0.571,0.1939 Q0.575,0.1939,0.5807,0.1919 T0.5888,0.1899 T0.5929,0.1909 Q0.5969,0.1949,0.6123,0.1949 Q0.6334,0.1949,0.6456,0.1899 Q0.6488,0.1879,0.6537,0.1879 Q0.661,0.1879,0.6772,0.1929 T0.6983,0.1978 Q0.704,0.1988,0.7206,0.1998 T0.7502,0.2018 T0.7664,0.2038 Q0.7672,0.2038,0.7818,0.2043 T0.8151,0.2067 T0.8459,0.2156 Q0.8573,0.2206,0.8573,0.2255 Q0.8573,0.2295,0.8516,0.2324 T0.8394,0.2369 T0.8281,0.2384 Q0.8208,0.2384,0.7526,0.2473 Q0.7494,0.2483,0.7421,0.2493 T0.7287,0.2512 T0.7178,0.2522 L0.7153,0.2522 L0.7137,0.2522 Q0.7056,0.2522,0.6898,0.2572 T0.6691,0.2671 Q0.6667,0.269,0.6634,0.269 Q0.661,0.269,0.6557,0.2681 T0.6472,0.2671 Q0.6448,0.2671,0.6415,0.2681 Q0.631,0.271,0.6034,0.274 Q0.5994,0.274,0.5961,0.2765 T0.5904,0.2789 L0.5864,0.2789 Q0.5831,0.2779,0.5742,0.2779 Q0.541,0.2779,0.5223,0.2849 Q0.5126,0.2888,0.5028,0.2888 Q0.5004,0.2888,0.4943,0.2883 T0.4842,0.2878 Q0.4704,0.2878,0.4627,0.2898 T0.4509,0.2987 Q0.4501,0.2997,0.4501,0.3007 Q0.4501,0.3046,0.4574,0.3066 T0.4765,0.3091 T0.4899,0.3096 Q0.4931,0.3106,0.5061,0.3121 T0.5316,0.315 T0.5499,0.3175 Q0.5507,0.3175,0.5572,0.3195 T0.5685,0.3225 T0.5803,0.3254 T0.5933,0.3279 T0.605,0.3284 T0.6148,0.3274 Q0.6221,0.3244,0.6294,0.3244 Q0.6399,0.3244,0.6496,0.3294 Q0.6529,0.3314,0.6577,0.3314 Q0.6602,0.3314,0.6663,0.3304 T0.6756,0.3294 T0.6813,0.3304 Q0.6837,0.3304,0.6873,0.3309 L0.6946,0.3318 T0.7024,0.3323 T0.7105,0.3328 T0.7178,0.3338 T0.7238,0.3348 T0.7283,0.3363 Q0.7324,0.3383,0.7482,0.3403 T0.7689,0.3442 Q0.7697,0.3442,0.779,0.3477 T0.794,0.3536 T0.8074,0.3615 T0.8183,0.3729 Q0.8232,0.3808,0.8232,0.3848 Q0.8232,0.3867,0.8216,0.3892 T0.8151,0.3961 T0.8062,0.406 T0.7997,0.4179 T0.7952,0.4263 T0.7855,0.4308 T0.7616,0.4362 Q0.7543,0.4372,0.7445,0.4372 L0.7372,0.4372 L0.7307,0.4372 Q0.7202,0.4372,0.7153,0.4382 Q0.7064,0.4402,0.691,0.4402 L0.6845,0.4402 L0.6788,0.4402 Q0.6699,0.4402,0.6533,0.4436 T0.6318,0.451 Q0.6302,0.451,0.6302,0.452 Q0.6302,0.457,0.6626,0.459 Q0.6869,0.4609,0.7015,0.4659 Q0.7064,0.4669,0.7202,0.4669 L0.7311,0.4669 L0.7429,0.4669 Q0.7599,0.4669,0.7689,0.4688 Q0.7794,0.4708,0.7794,0.4738 L0.7786,0.4748 T0.777,0.4763 T0.7749,0.4773 L0.7737,0.4777 L0.7729,0.4777 Q0.7502,0.4797,0.738,0.4797 L0.7315,0.4797 L0.7291,0.4797 Q0.7226,0.4797,0.6869,0.4876 Q0.6821,0.4886,0.6768,0.4891 T0.6614,0.4901 T0.6456,0.4916 L0.6342,0.4916 Q0.6294,0.4916,0.6217,0.4911 T0.6115,0.4906 Q0.6083,0.4906,0.6054,0.4911 T0.5961,0.4936 T0.5815,0.4965 T0.5629,0.4985 Q0.5507,0.4985,0.5442,0.4995 T0.5345,0.5025 T0.5304,0.5084 L0.5296,0.5094 Q0.5296,0.5114,0.5316,0.5124 T0.5373,0.5138 T0.5442,0.5143 L0.5523,0.5143 L0.5604,0.5143 Q0.5637,0.5153,0.5677,0.5158 T0.5742,0.5173 T0.5811,0.5188 T0.5904,0.5193 L0.5977,0.5193 L0.6026,0.5193 Q0.6107,0.5193,0.6168,0.5208 T0.6294,0.5252 T0.6399,0.5292 Q0.6448,0.5302,0.6679,0.5341 T0.6983,0.541 Q0.7129,0.545,0.7664,0.55 Q0.7883,0.5519,0.7883,0.5559 Q0.7883,0.5579,0.7843,0.5608 T0.7721,0.5638 Q0.7697,0.5638,0.764,0.5633 T0.7559,0.5628 L0.7502,0.5628 Q0.7364,0.5648,0.6869,0.5648 L0.6699,0.5648 L0.6675,0.5648 Q0.665,0.5648,0.6602,0.5658 T0.6513,0.5668 Q0.6464,0.5668,0.6399,0.5658 Q0.6342,0.5648,0.6302,0.5648 T0.6225,0.5668 T0.6156,0.5697 Q0.6107,0.5707,0.6087,0.5707 T0.6034,0.5712 T0.5953,0.5717 Q0.5904,0.5727,0.5835,0.5727 T0.5718,0.5732 T0.562,0.5757 Q0.558,0.5767,0.5483,0.5767 L0.5349,0.5767 L0.5207,0.5767 Q0.5069,0.5767,0.5028,0.5786 Q0.4972,0.5816,0.4899,0.5816 Q0.4858,0.5816,0.4781,0.5806 T0.4663,0.5796 L0.4631,0.5796 Q0.4582,0.5796,0.4432,0.5821 T0.4144,0.5846 L0.4071,0.5846 L0.4011,0.5846 T0.3958,0.5851 T0.3921,0.5856 T0.3889,0.5861 T0.3865,0.5865 T0.384,0.5875 L0.382,0.5885 L0.3796,0.5895 L0.3771,0.5905 Q0.3731,0.5925,0.365,0.5925 L0.3601,0.5925 L0.3552,0.5925 Q0.3277,0.5925,0.3049,0.6044 Q0.3017,0.6053,0.296,0.6053 Q0.292,0.6053,0.2847,0.6044 T0.2741,0.6034 Q0.2693,0.6034,0.2547,0.6029 T0.236,0.6024 Q0.2222,0.6024,0.1922,0.6073 Q0.1906,0.6083,0.1882,0.6083 Q0.1833,0.6083,0.1711,0.6063 T0.1533,0.6044 Q0.1517,0.6034,0.1492,0.6034 Q0.1436,0.6034,0.135,0.6048 T0.1168,0.6078 T0.103,0.6093 Q0.0973,0.6103,0.0787,0.6103 L0.0604,0.6103 L0.0414,0.6103 L0.0235,0.6103 Q0.0024,0.6113,0.0024,0.6172 Q0.0024,0.6192,0.0032,0.6231 Q0.0049,0.6281,0.0333,0.6281 L0.0414,0.6281 L0.0479,0.6281 Q0.0519,0.6281,0.0624,0.6301 T0.0803,0.632 L0.0835,0.632 L0.0916,0.632 Q0.0989,0.632,0.1281,0.634 L0.206,0.6419 Q0.2117,0.6429,0.2174,0.6429 Q0.2214,0.6429,0.2271,0.6424 T0.2344,0.6419 L0.2376,0.6419 Q0.2393,0.6429,0.2417,0.6429 T0.2526,0.6419 T0.2652,0.6409 Q0.2741,0.6409,0.2782,0.6429 T0.2968,0.6459 T0.3293,0.6479 T0.352,0.6489 Q0.3552,0.6499,0.3585,0.6499 Q0.365,0.6499,0.3731,0.6474 T0.3844,0.6449 Q0.3885,0.6449,0.3917,0.6474 T0.4006,0.6499 Q0.4088,0.6499,0.4509,0.6578 Q0.4574,0.6588,0.4631,0.6588 Q0.4655,0.6588,0.4716,0.6583 T0.4834,0.6578 L0.4874,0.6578 Q0.5004,0.6588,0.5255,0.6637 Q0.532,0.6647,0.56,0.6667 T0.6148,0.6701 T0.644,0.6716 Q0.6464,0.6726,0.6594,0.6736 T0.6861,0.6756 T0.7048,0.6775 Q0.7097,0.6775,0.7186,0.6825 T0.738,0.6874 Q0.7518,0.6874,0.7729,0.6958 T0.8005,0.7122 Q0.8029,0.7141,0.8029,0.7151 Q0.8029,0.7191,0.7968,0.7216 T0.7818,0.726 T0.7705,0.729 L0.764,0.731 T0.7583,0.7324 T0.753,0.7334 T0.7486,0.7349 Q0.7251,0.7399,0.7161,0.7399 Q0.7097,0.7399,0.6951,0.7409 T0.6748,0.7418 L0.6703,0.7418 L0.6667,0.7418 Q0.6659,0.7418,0.6399,0.7448 L0.6334,0.7448 Q0.631,0.7448,0.6249,0.7443 T0.6148,0.7438 L0.6099,0.7438 Q0.601,0.7448,0.5856,0.7473 T0.5645,0.7507 Q0.5572,0.7517,0.5572,0.7547 T0.5616,0.7606 T0.5702,0.7646 Q0.5734,0.7646,0.5779,0.7651 T0.5864,0.7661 T0.5949,0.7671 T0.6018,0.7676 L0.6071,0.7676 T0.6135,0.7681 T0.62,0.769 T0.6269,0.771 T0.6342,0.7725 Q0.6391,0.7725,0.6464,0.7705 T0.6541,0.7685 L0.6553,0.7685 Q0.6586,0.7685,0.6699,0.771 T0.6853,0.7735 Q0.6902,0.7735,0.704,0.777 T0.7226,0.7834 Q0.7315,0.7873,0.7445,0.7883 Q0.747,0.7883,0.7676,0.7893 T0.7972,0.7913 Q0.7989,0.7913,0.8017,0.7918 T0.8058,0.7923 T0.809,0.7928 T0.8114,0.7933 T0.8131,0.7938 T0.8147,0.7943 T0.8159,0.7953 L0.8171,0.7967 T0.8175,0.7982 Q0.8183,0.7992,0.8183,0.8002 Q0.8183,0.8022,0.8155,0.8032 T0.807,0.8051 T0.7989,0.8071 Q0.7932,0.8091,0.7737,0.8091 L0.7413,0.8091 L0.7372,0.8091 Q0.7275,0.8091,0.721,0.8101 T0.7072,0.8136 T0.6983,0.817 Q0.6942,0.818,0.6825,0.818 T0.6602,0.819 T0.6456,0.822 Q0.6415,0.8249,0.6375,0.8249 Q0.6367,0.8249,0.6326,0.8239 T0.6253,0.8229 Q0.6034,0.8229,0.5953,0.8279 Q0.5912,0.8289,0.5848,0.8289 Q0.5775,0.8289,0.5645,0.8279 Q0.5629,0.8269,0.5604,0.8269 Q0.5523,0.8269,0.5328,0.8348 Q0.5296,0.8358,0.5264,0.8358 Q0.5247,0.8358,0.5203,0.8353 T0.5126,0.8348 Q0.5061,0.8348,0.4988,0.8358 Q0.4947,0.8368,0.4882,0.8378 Q0.4826,0.8388,0.4797,0.8388 T0.474,0.8398 T0.4704,0.8422 T0.4696,0.8462 T0.472,0.8501 T0.4781,0.8521 T0.4854,0.8526 L0.4935,0.8526 T0.4996,0.8536 Q0.5036,0.8536,0.515,0.8546 T0.5337,0.8566 Q0.5426,0.8576,0.5596,0.8576 L0.5657,0.8576 L0.571,0.8576 L0.5791,0.8576 Q0.5921,0.8586,0.6135,0.8591 T0.6383,0.8605 Q0.6683,0.8635,0.6764,0.8635 Q0.6999,0.8635,0.6999,0.8684 Q0.6999,0.8694,0.6967,0.8734 T0.6934,0.8803 L0.6934,0.8823 Q0.6951,0.8863,0.7137,0.8863 Q0.7218,0.8863,0.7267,0.8853 L0.7299,0.8853 Q0.734,0.8853,0.7409,0.8882 T0.7502,0.8912 Q0.7607,0.8942,0.794,0.8971 Q0.7972,0.8971,0.8078,0.9001 T0.8297,0.9031 Q0.8475,0.9031,0.8646,0.9065 T0.8824,0.9149 L0.8824,0.9159 Q0.8824,0.9169,0.882,0.9179 T0.8808,0.9194 L0.8792,0.9204 T0.8767,0.9214 T0.8743,0.9224 T0.8715,0.9228 L0.8686,0.9228 T0.8662,0.9233 T0.8637,0.9238 L0.8556,0.9238 T0.8447,0.9243 T0.8386,0.9268 Q0.837,0.9278,0.835,0.9288 T0.8305,0.9303 T0.8244,0.9308 T0.8175,0.9318 Q0.8127,0.9327,0.8017,0.9352 T0.7851,0.9397 Q0.777,0.9416,0.7705,0.9451 T0.7616,0.9496 Q0.7575,0.9505,0.7494,0.9505 L0.7405,0.9505 L0.7307,0.9505 Q0.7234,0.9505,0.7178,0.9515 Q0.7145,0.9515,0.7125,0.9535 T0.7105,0.9575 Q0.7105,0.9624,0.7194,0.9634 Q0.7567,0.9654,0.7664,0.9674 Q0.7745,0.9693,0.7762,0.9693 L0.7908,0.9693 L0.7948,0.9693 Q0.8005,0.9693,0.8058,0.9698 T0.8143,0.9718 T0.824,0.9753 T0.8354,0.9792 Q0.8483,0.9832,0.871,0.9832 L0.8739,0.9832 L0.8767,0.9832 Q0.8873,0.9832,0.8921,0.9842 Q0.8978,0.9852,0.9157,0.9896 T0.94,0.996 Q0.9424,0.996,0.9469,0.9965 T0.9566,0.997 T0.9676,0.9975 T0.9781,0.998 L0.9874,0.998 L0.9943,0.998 L0.9968,0.998 Z",type:0}},"\uE0CC":{type:5,data:{d:"M1,0.5071 L0.8623,0.7102 L0.5865,0.7102 L0.4489,0.5071 L0.5865,0.3045 L0.8623,0.3045 Z M0.4135,0.7974 L0.2758,1 L0,1 L0,0.5944 L0.2758,0.5944 Z M0.4135,0.2026 L0.2758,0.4056 L0,0.4056 L0,0 L0.2758,0 Z",type:0}},"\uE0CD":{type:5,data:{d:"M0.9726,0.5071 L0.8451,0.3184 L0.59,0.3184 L0.4624,0.5071 L0.59,0.6955 L0.8451,0.6955 Z M1,0.5071 L0.8588,0.716 L0.5768,0.716 L0.4355,0.5071 L0.5768,0.2982 L0.8588,0.2982 Z M0.401,0.7911 L0.2735,0.6028 L0.0236,0.6028 L0.0236,0.9799 L0.2735,0.9799 Z M0.4284,0.7911 L0.2872,1 L0,1 L0,0.5826 L0.2872,0.5826 Z M0.401,0.2089 L0.2735,0.0201 L0.0236,0.0201 L0.0236,0.3972 L0.2735,0.3972 Z M0.4284,0.2089 L0.2872,0.4174 L0,0.4174 L0,0 L0.2872,0 Z",type:0}},"\uE0CE":{type:5,data:{d:"M1,0.4996 Q1,0.5365,0.9924,0.568 T0.9718,0.6177 T0.9433,0.6363 L0.9425,0.6367 Q0.9355,0.6367,0.9285,0.6326 L0.9308,0.6326 Q0.9129,0.6195,0.9013,0.587 T0.8871,0.5164 T0.8943,0.4373 T0.9254,0.3687 L0.9254,0.3691 Q0.9339,0.3628,0.9425,0.3628 Q0.9584,0.3628,0.9716,0.3811 T0.9924,0.431 T1,0.4996 Z M0.9234,0.6321 L0.8776,0.7169 Q0.8791,0.732,0.8791,0.7441 Q0.8791,0.781,0.8716,0.8123 T0.8508,0.862 T0.8224,0.8809 L0.8216,0.8809 Q0.8154,0.8809,0.8092,0.8775 L0.8088,0.8775 Q0.7905,0.8633,0.7792,0.8297 T0.766,0.7571 T0.775,0.6768 T0.8088,0.6086 L0.7431,0.6065 Q0.719,0.6313,0.7072,0.6705 T0.6965,0.7487 T0.7097,0.823 T0.7423,0.8763 L0.7913,0.8775 L0.7314,0.9895 L0.4648,0.5038 L0.7299,0.0176 L0.7882,0.1229 L0.7404,0.1212 Q0.7244,0.138,0.7136,0.1617 T0.6982,0.2108 T0.6943,0.2632 T0.7003,0.3144 T0.7159,0.3586 T0.7396,0.3909 L0.8072,0.3926 Q0.7894,0.3796,0.7777,0.3479 T0.7635,0.2789 T0.7697,0.2011 T0.7987,0.1326 Q0.8092,0.1225,0.8204,0.1225 Q0.8321,0.1225,0.8426,0.1332 T0.8609,0.1623 T0.8731,0.2062 T0.8776,0.2592 Q0.8776,0.268,0.8768,0.2815 L0.9219,0.3628 L0.8644,0.3612 Q0.8403,0.3855,0.8284,0.4247 T0.8177,0.5029 T0.8309,0.5772 T0.8632,0.6305 Z M0.6759,0.6384 Q0.6576,0.625,0.6457,0.5914 T0.6319,0.5185 T0.6407,0.4375 T0.6751,0.3691 L0.609,0.367 Q0.5849,0.3918,0.5731,0.431 T0.5626,0.509 T0.5758,0.5833 T0.6082,0.6367 Z M0.6459,0.0025 L0.391,0.4551 L0.0035,0.4551 L0.246,0 Z M0.6553,1 L0.2421,1 L0,0.5449 L0.3875,0.5449 Z",type:0}},"\uE0CF":{type:5,data:{d:"M0.4996,0 Q0.5562,0,0.5961,0.0166 T0.6364,0.0566 L0.6368,0.0575 Q0.6368,0.0646,0.6324,0.0713 L0.6324,0.0692 Q0.6196,0.0868,0.5872,0.0986 T0.5166,0.1128 T0.4373,0.1057 T0.3689,0.0747 L0.3694,0.0747 Q0.3627,0.0663,0.3627,0.0575 Q0.3627,0.0336,0.4028,0.0168 T0.4996,0 Z M0.6324,0.0768 L0.717,0.1225 Q0.7321,0.1212,0.744,0.1212 Q0.8007,0.1212,0.8406,0.1378 T0.8809,0.1779 L0.8813,0.1783 Q0.8813,0.1846,0.8778,0.1909 Q0.8636,0.2093,0.8299,0.2208 T0.7573,0.2343 T0.6769,0.2253 T0.6089,0.1913 L0.6067,0.2567 Q0.6315,0.281,0.6707,0.2928 T0.7489,0.3035 T0.8231,0.2903 T0.8764,0.258 L0.8778,0.2085 L0.9898,0.2685 L0.504,0.5352 L0.0177,0.2701 L0.1227,0.2118 L0.1213,0.2596 Q0.1457,0.2836,0.1851,0.2955 T0.2635,0.3062 T0.3377,0.293 T0.3911,0.2605 L0.3928,0.193 Q0.38,0.2106,0.3483,0.2221 T0.2792,0.2364 T0.2013,0.2303 T0.1324,0.2013 Q0.1222,0.1909,0.1222,0.1795 Q0.1222,0.156,0.1623,0.1393 T0.2591,0.1225 Q0.2679,0.1225,0.2817,0.1233 L0.3627,0.078 L0.3609,0.1359 Q0.3857,0.1598,0.4249,0.1718 T0.5031,0.1825 T0.5773,0.1693 T0.6306,0.1367 Z M0.6386,0.3242 Q0.6253,0.3427,0.5917,0.3544 T0.5186,0.3683 T0.4376,0.3595 T0.3689,0.3251 L0.3671,0.3909 Q0.3915,0.4148,0.4309,0.4268 T0.5093,0.4375 T0.5835,0.4243 T0.6368,0.3918 Z M0.0027,0.354 L0.4553,0.6091 L0.4553,0.9962 L0,0.7542 Z M1,0.3448 L1,0.7576 L0.5447,1 L0.5447,0.6128 Z",type:0}},"\uE0D0":{type:5,data:{d:"M0,0.7699 Q0,0.7172,0.0277,0.6731 T0.103,0.6032 T0.2066,0.5774 Q0.2487,0.5774,0.2865,0.5922 T0.3526,0.6339 L0.3935,0.6715 Q0.4218,0.6984,0.438,0.7336 T0.4541,0.8075 Q0.4541,0.8602,0.4264,0.9046 T0.3511,0.9745 T0.2475,1 Q0.2054,1,0.1676,0.9852 T0.1016,0.9435 L0.0606,0.9059 Q0.0317,0.879,0.0159,0.8438 T0,0.7699 Z M0.5459,0.7699 Q0.5459,0.7172,0.5736,0.6731 T0.6489,0.6032 T0.7525,0.5774 Q0.7946,0.5774,0.8324,0.5922 T0.8984,0.6339 L0.9394,0.6715 Q0.9579,0.6892,0.9714,0.7105 T0.9925,0.7567 T1,0.8075 Q1,0.8602,0.9723,0.9046 T0.897,0.9745 T0.7934,1 Q0.7513,1,0.7135,0.9852 T0.6474,0.9435 L0.6065,0.9059 Q0.5776,0.879,0.5617,0.8438 T0.5459,0.7699 Z M0,0.1925 Q0,0.1398,0.0277,0.0957 T0.103,0.0258 T0.2066,0 Q0.2487,0,0.2865,0.0148 T0.3526,0.0565 L0.3935,0.0941 Q0.4218,0.121,0.438,0.1562 T0.4541,0.2301 Q0.4541,0.2828,0.4264,0.3269 T0.3511,0.3968 T0.2475,0.4226 Q0.2054,0.4226,0.1676,0.4078 T0.1016,0.3661 L0.0606,0.3285 Q0.0317,0.3016,0.0159,0.2664 T0,0.1925 Z M0.5459,0.1925 Q0.5459,0.1398,0.5736,0.0957 T0.6489,0.0258 T0.7525,0 Q0.7946,0,0.8324,0.0148 T0.8984,0.0565 L0.9394,0.0941 Q0.9677,0.121,0.9838,0.1562 T1,0.2301 Q1,0.2828,0.9723,0.3269 T0.897,0.3968 T0.7934,0.4226 Q0.7513,0.4226,0.7135,0.4078 T0.6474,0.3661 L0.6065,0.3285 Q0.5874,0.3108,0.5741,0.2895 T0.5534,0.2433 T0.5459,0.1925 Z M0.3301,0.6403 Q0.3306,0.6403,0.3312,0.6409 L0.3272,0.6371 L0.3272,0.6371 Z M0.8759,0.6403 Q0.8765,0.6403,0.8771,0.6409 Q0.8748,0.6387,0.8725,0.6371 L0.8731,0.6371 Z M0.3301,0.0629 Q0.3306,0.0629,0.3312,0.0634 L0.3272,0.0597 L0.3272,0.0602 Z M0.8759,0.0629 Q0.8765,0.0629,0.8771,0.0634 Q0.8748,0.0613,0.8725,0.0597 Q0.8731,0.0597,0.8731,0.0602 Z M0.779,0.4113 L0.7501,0.3849 Q0.7046,0.3844,0.6636,0.3661 Q0.7132,0.407,0.779,0.4113 Z M0.7957,0.4118 Q0.8477,0.4113,0.8918,0.3874 T0.9619,0.3223 T0.9885,0.2323 L0.9585,0.2048 Q0.9538,0.2769,0.8987,0.3282 T0.7657,0.3844 Z M0.9879,0.2167 Q0.9827,0.1554,0.9388,0.1097 Q0.9585,0.1478,0.959,0.1898 Z M0.2331,0.4113 L0.2043,0.3849 Q0.1587,0.3844,0.1177,0.3661 Q0.1673,0.407,0.2331,0.4113 Z M0.2499,0.4118 Q0.2885,0.4118,0.324,0.3973 T0.3852,0.3589 T0.4264,0.3019 T0.4426,0.2323 L0.4126,0.2048 Q0.408,0.2769,0.3529,0.3282 T0.2198,0.3844 Z M0.442,0.2167 Q0.4368,0.1554,0.393,0.1097 Q0.4126,0.1478,0.4132,0.1903 Z M0.779,0.9887 L0.7501,0.9624 Q0.7046,0.9618,0.6636,0.9435 Q0.7132,0.9844,0.779,0.9887 Z M0.7957,0.9892 Q0.8477,0.9887,0.8918,0.9648 T0.9619,0.8997 T0.9885,0.8097 L0.9585,0.7823 Q0.9538,0.8543,0.8987,0.9056 T0.7657,0.9618 Z M0.9879,0.7941 Q0.9827,0.7328,0.9388,0.6871 Q0.9585,0.7253,0.959,0.7672 Z M0.2331,0.9887 L0.2043,0.9624 Q0.1587,0.9618,0.1177,0.9435 Q0.1673,0.9844,0.2331,0.9887 Z M0.2499,0.9892 Q0.3018,0.9887,0.3459,0.9648 T0.416,0.8997 T0.4426,0.8102 L0.4126,0.7823 Q0.408,0.8543,0.3529,0.9056 T0.2198,0.9618 Z M0.442,0.7941 Q0.4368,0.7328,0.393,0.6871 Q0.4126,0.7253,0.4132,0.7672 Z M0.3191,0.6452 Q0.2943,0.622,0.2643,0.6091 T0.2066,0.5962 Q0.1795,0.5962,0.1497,0.6091 T0.0949,0.6441 T0.0537,0.6995 T0.0375,0.7699 Q0.0375,0.8419,0.0941,0.8941 Q0.1189,0.9172,0.1489,0.9304 T0.2066,0.9435 Q0.2337,0.9435,0.2634,0.9304 T0.3182,0.8952 T0.3595,0.8398 T0.3756,0.7699 Q0.3756,0.6978,0.3191,0.6452 Z M0.026,0.7731 L0.026,0.7699 Q0.026,0.6935,0.0854,0.6376 Q0.0658,0.6538,0.0514,0.6739 T0.0289,0.718 T0.0202,0.7677 Z M0.0271,0.7892 L0.0208,0.7833 Q0.0254,0.8419,0.0681,0.886 Q0.0664,0.8828,0.0646,0.879 Q0.0329,0.8382,0.0271,0.7892 Z M0.3866,0.7823 Q0.382,0.8511,0.3278,0.9016 Q0.3843,0.8565,0.3918,0.7871 Z M0.3872,0.7677 L0.393,0.7726 L0.393,0.7699 Q0.393,0.7349,0.3791,0.7038 Q0.3739,0.6973,0.3681,0.6914 Q0.3866,0.7274,0.3872,0.7677 Z M0.865,0.6452 Q0.8402,0.622,0.8102,0.6091 T0.7525,0.5962 Q0.7305,0.5962,0.7072,0.6043 T0.6619,0.628 T0.6223,0.6642 T0.5941,0.7124 T0.5834,0.7699 Q0.5834,0.8419,0.6399,0.8941 Q0.6561,0.9097,0.6757,0.9207 T0.7149,0.9376 T0.7525,0.9435 Q0.7796,0.9435,0.8093,0.9304 T0.8641,0.8952 T0.9054,0.8398 T0.9215,0.7699 Q0.9215,0.6978,0.865,0.6452 Z M0.5718,0.7731 L0.5718,0.7699 Q0.5718,0.6935,0.6313,0.6376 Q0.6013,0.6618,0.584,0.6954 T0.5661,0.7677 Z M0.573,0.7892 L0.5666,0.7833 Q0.5713,0.8419,0.614,0.886 Q0.6122,0.8828,0.6105,0.879 Q0.5788,0.8382,0.573,0.7892 Z M0.9325,0.7823 Q0.9279,0.8511,0.8736,0.9016 Q0.9308,0.8565,0.9377,0.7871 Z M0.9331,0.7672 L0.9388,0.7726 L0.9388,0.7699 Q0.9388,0.7349,0.925,0.7038 Q0.9198,0.6973,0.914,0.6914 Q0.9325,0.7274,0.9331,0.7672 Z M0.3191,0.0677 Q0.2943,0.0446,0.2643,0.0317 T0.2066,0.0188 Q0.1795,0.0188,0.1497,0.0317 T0.0949,0.0667 T0.0537,0.122 T0.0375,0.1925 Q0.0375,0.2645,0.0941,0.3167 Q0.1189,0.3398,0.1489,0.353 T0.2066,0.3661 Q0.2337,0.3661,0.2634,0.353 T0.3182,0.3177 T0.3595,0.2624 T0.3756,0.1925 Q0.3756,0.1204,0.3191,0.0677 Z M0.026,0.1957 L0.026,0.1925 Q0.026,0.1161,0.0854,0.0602 Q0.0554,0.0844,0.0381,0.118 T0.0202,0.1903 Z M0.0271,0.2118 L0.0208,0.2059 Q0.0254,0.2645,0.0681,0.3086 Q0.0664,0.3054,0.0646,0.3016 Q0.0329,0.2608,0.0271,0.2118 Z M0.3866,0.2048 Q0.382,0.2737,0.3278,0.3242 Q0.3849,0.279,0.3918,0.2097 Z M0.3872,0.1903 L0.393,0.1952 L0.393,0.1925 Q0.393,0.1581,0.3791,0.1263 Q0.3739,0.1199,0.3681,0.114 Q0.3866,0.15,0.3872,0.1903 Z M0.865,0.0677 Q0.8402,0.0446,0.8102,0.0317 T0.7525,0.0188 Q0.7253,0.0188,0.6956,0.0317 T0.6408,0.0667 T0.5995,0.122 T0.5834,0.1925 Q0.5834,0.2645,0.6399,0.3167 Q0.6647,0.3398,0.6947,0.353 T0.7525,0.3661 Q0.7796,0.3661,0.8093,0.353 T0.8641,0.3177 T0.9054,0.2624 T0.9215,0.1925 Q0.9215,0.1204,0.865,0.0677 Z M0.5718,0.1957 L0.5718,0.1925 Q0.5718,0.1161,0.6313,0.0602 Q0.6013,0.0844,0.584,0.118 T0.5661,0.1903 Z M0.573,0.2118 L0.5666,0.2059 Q0.5713,0.2645,0.614,0.3086 Q0.6122,0.3054,0.6105,0.3016 Q0.5788,0.2608,0.573,0.2118 Z M0.9325,0.2048 Q0.9279,0.2737,0.8736,0.3242 Q0.9308,0.279,0.9377,0.2097 Z M0.9331,0.1898 L0.9388,0.1952 L0.9388,0.1925 Q0.9388,0.1575,0.925,0.1263 Q0.9198,0.1199,0.914,0.114 Q0.9325,0.15,0.9331,0.1898 Z",type:0}},"\uE0D1":{type:5,data:{d:"M0.7639,0.1974 L0.9593,0.1974 L0.9593,0.1443 L0.7639,0.1443 L0.7639,0.1974 Z M0.7586,0.109 L0.9727,0.109 Q0.9839,0.109,0.992,0.1153 T1,0.1291 L1,0.382 Q1,0.3906,0.9912,0.3964 T0.9727,0.4021 L0.7586,0.4021 Q0.7468,0.4021,0.739,0.3956 T0.7313,0.382 L0.7313,0.1291 Q0.7313,0.1246,0.7339,0.1207 T0.7406,0.1143 T0.7495,0.1104 T0.7586,0.109 Z M0.7639,0.6575 L0.9593,0.6575 L0.9593,0.6044 L0.7639,0.6044 L0.7639,0.6575 Z M0.7313,0.8347 L0.7313,0.5843 Q0.7313,0.5757,0.7401,0.5699 T0.7586,0.5641 L0.9706,0.5641 Q0.9818,0.5641,0.9898,0.5705 T0.9979,0.5843 L0.9979,0.8347 Q0.9979,0.8433,0.989,0.8491 T0.9706,0.8549 L0.7586,0.8549 Q0.7468,0.8549,0.739,0.8483 T0.7313,0.8347 Z M0,1 L0,0 L0.7607,0 L0.7607,1 L0,1 Z",type:0}},"\uE0D2":{type:5,data:{d:"M0.9764,0.0029 L0.3179,0.4552 L0,0.4552 L0,0 Z M1,1 L0,1 L0,0.5448 L0.3091,0.5448 Z",type:0}},"\uE0D4":{type:5,data:{d:"M0.0236,0.0029 L0.6824,0.4552 L1,0.4552 L1,0 Z M0,1 L1,1 L1,0.5448 L0.6912,0.5448 Z",type:0}},"\uEE00":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z"},"\uEE01":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z"},"\uEE02":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z"},"\uEE03":{type:3,data:"M0,0 L1,0 L1,.05 L.1,.05 L.1,.95 L1,.95 L1,1 L0,1 Z M.25,.15 L1,.15 L1,.85 L.25,.85 Z"},"\uEE04":{type:3,data:"M0,0 L1,0 L1,.05 L0,.05 Z M0,.95 L1,.95 L1,1 L0,1 Z M0,.15 L1,.15 L1,.85 L0,.85 Z"},"\uEE05":{type:3,data:"M1,0 L0,0 L0,.05 L.9,.05 L.9,.95 L0,.95 L0,1 L1,1 Z M0,.15 L.75,.15 L.75,.85 L0,.85 Z"},"\uEE06":{type:5,data:{d:"M0.6574,0.303 Q0.623,0.291,0.5852,0.285 T0.5082,0.279 T0.4311,0.285 T0.359,0.303 L0.3082,0.248 Q0.3525,0.232,0.4041,0.2235 T0.5082,0.215 T0.6123,0.2235 T0.7082,0.248 Z",type:0},scaleType:1},"\uEE07":{type:5,data:{d:"M0.8557,0.582 L0.7656,0.551 Q0.7852,0.53,0.7951,0.507 T0.8049,0.46 Q0.8049,0.424,0.782,0.3905 T0.718,0.332 T0.6221,0.293 T0.5082,0.279 L0.5082,0.215 Q0.5607,0.215,0.6123,0.2235 T0.709,0.248 T0.7918,0.287 T0.8557,0.3375 T0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.582 Z",type:0},scaleType:1},"\uEE08":{type:5,data:{d:"M0.5082,0.705 Q0.482,0.705,0.4557,0.703 T0.4049,0.697 L0.4311,0.635 Q0.4508,0.638,0.4697,0.6395 T0.5082,0.641 Q0.5672,0.641,0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 Q0.8049,0.436,0.7951,0.413 T0.7656,0.369 L0.8557,0.338 Q0.882,0.365,0.8959,0.3965 T0.9098,0.46 T0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE09":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 L0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 T0.6221,0.627 T0.718,0.588 T0.782,0.5295 T0.8049,0.46 L0.9098,0.46 Q0.9098,0.492,0.8959,0.5235 T0.8557,0.5825 T0.7918,0.633 T0.709,0.672 T0.6123,0.6965 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0A":{type:5,data:{d:"M0.5082,0.705 Q0.4557,0.705,0.4041,0.6965 T0.3074,0.672 T0.2246,0.633 T0.1607,0.5825 T0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.338 L0.2508,0.369 Q0.2311,0.39,0.2213,0.413 T0.2115,0.46 Q0.2115,0.496,0.2344,0.5295 T0.2984,0.588 T0.3943,0.627 T0.5082,0.641 Q0.5279,0.641,0.5467,0.6395 T0.5852,0.635 L0.6115,0.697 Q0.5869,0.701,0.5607,0.703 T0.5082,0.705 Z",type:0},scaleType:1},"\uEE0B":{type:5,data:{d:"M0.1607,0.582 Q0.1344,0.555,0.1205,0.5235 T0.1066,0.46 T0.1205,0.3965 T0.1607,0.3375 T0.2246,0.287 T0.3074,0.248 T0.4041,0.2235 T0.5082,0.215 L0.5082,0.279 Q0.4492,0.279,0.3943,0.293 T0.2984,0.332 T0.2344,0.3905 T0.2115,0.46 Q0.2115,0.484,0.2213,0.507 T0.2508,0.551 Z",type:0},scaleType:1},"\uF5D0":l.LINE_H,"\uF5D1":l.LINE_V,"\uF5D2":l.FADE_RIGHT,"\uF5D3":l.FADE_LEFT,"\uF5D4":l.FADE_DOWN,"\uF5D5":l.FADE_UP,"\uF5D6":l.CURVE_DOWN_RIGHT,"\uF5D7":l.CURVE_DOWN_LEFT,"\uF5D8":l.CURVE_UP_RIGHT,"\uF5D9":l.CURVE_UP_LEFT,"\uF5DA":[l.LINE_V,l.CURVE_UP_RIGHT],"\uF5DB":[l.LINE_V,l.CURVE_DOWN_RIGHT],"\uF5DC":[l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5DD":[l.LINE_V,l.CURVE_UP_LEFT],"\uF5DE":[l.LINE_V,l.CURVE_DOWN_LEFT],"\uF5DF":[l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E0":[l.LINE_H,l.CURVE_DOWN_LEFT],"\uF5E1":[l.LINE_H,l.CURVE_DOWN_RIGHT],"\uF5E2":[l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E3":[l.LINE_H,l.CURVE_UP_LEFT],"\uF5E4":[l.LINE_H,l.CURVE_UP_RIGHT],"\uF5E5":[l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E6":[l.LINE_V,l.CURVE_UP_LEFT,l.CURVE_UP_RIGHT],"\uF5E7":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_DOWN_RIGHT],"\uF5E8":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_LEFT],"\uF5E9":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_RIGHT],"\uF5EA":[l.LINE_V,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5EB":[l.LINE_V,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EC":[l.LINE_H,l.CURVE_DOWN_RIGHT,l.CURVE_UP_LEFT],"\uF5ED":[l.LINE_H,l.CURVE_DOWN_LEFT,l.CURVE_UP_RIGHT],"\uF5EE":[l.NODE_FILL,l.NODE_STROKE],"\uF5EF":l.NODE_STROKE,"\uF5F0":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F1":[l.NODE_STROKE,l.NODE_LINE_RIGHT],"\uF5F2":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F3":[l.NODE_STROKE,l.NODE_LINE_LEFT],"\uF5F4":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F5":[l.NODE_STROKE,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF5F6":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F7":[l.NODE_STROKE,l.NODE_LINE_DOWN],"\uF5F8":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP],"\uF5F9":[l.NODE_STROKE,l.NODE_LINE_UP],"\uF5FA":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FB":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_UP],"\uF5FC":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FD":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF5FE":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF5FF":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF600":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF601":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_RIGHT],"\uF602":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF603":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT],"\uF604":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF605":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_RIGHT],"\uF606":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF607":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT],"\uF608":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF609":[l.NODE_STROKE,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60A":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60B":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60C":[l.NODE_FILL,l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\uF60D":[l.NODE_STROKE,l.NODE_LINE_UP,l.NODE_LINE_DOWN,l.NODE_LINE_LEFT,l.NODE_LINE_RIGHT],"\u{1FB00}":f(1),"\u{1FB01}":f(2),"\u{1FB02}":f(3),"\u{1FB03}":f(4),"\u{1FB04}":f(5),"\u{1FB05}":f(6),"\u{1FB06}":f(7),"\u{1FB07}":f(8),"\u{1FB08}":f(9),"\u{1FB09}":f(10),"\u{1FB0A}":f(11),"\u{1FB0B}":f(12),"\u{1FB0C}":f(13),"\u{1FB0D}":f(14),"\u{1FB0E}":f(15),"\u{1FB0F}":f(16),"\u{1FB10}":f(17),"\u{1FB11}":f(18),"\u{1FB12}":f(19),"\u{1FB13}":f(20),"\u{1FB14}":f(22),"\u{1FB15}":f(23),"\u{1FB16}":f(24),"\u{1FB17}":f(25),"\u{1FB18}":f(26),"\u{1FB19}":f(27),"\u{1FB1A}":f(28),"\u{1FB1B}":f(29),"\u{1FB1C}":f(30),"\u{1FB1D}":f(31),"\u{1FB1E}":f(32),"\u{1FB1F}":f(33),"\u{1FB20}":f(34),"\u{1FB21}":f(35),"\u{1FB22}":f(36),"\u{1FB23}":f(37),"\u{1FB24}":f(38),"\u{1FB25}":f(39),"\u{1FB26}":f(40),"\u{1FB27}":f(41),"\u{1FB28}":f(43),"\u{1FB29}":f(44),"\u{1FB2A}":f(45),"\u{1FB2B}":f(46),"\u{1FB2C}":f(47),"\u{1FB2D}":f(48),"\u{1FB2E}":f(49),"\u{1FB2F}":f(50),"\u{1FB30}":f(51),"\u{1FB31}":f(52),"\u{1FB32}":f(53),"\u{1FB33}":f(54),"\u{1FB34}":f(55),"\u{1FB35}":f(56),"\u{1FB36}":f(57),"\u{1FB37}":f(58),"\u{1FB38}":f(59),"\u{1FB39}":f(60),"\u{1FB3A}":f(61),"\u{1FB3B}":f(62),"\u{1FB3C}":{type:3,data:"M0,0.6667 L0,1 L0.5,1 Z"},"\u{1FB3D}":{type:3,data:"M0,0.6667 L0,1 L1,1 Z"},"\u{1FB3E}":{type:3,data:"M0,0.3333 L0,1 L0.5,1 Z"},"\u{1FB3F}":{type:3,data:"M0,0.3333 L0,1 L1,1 Z"},"\u{1FB40}":{type:3,data:"M0,0 L0,1 L0.5,1 Z"},"\u{1FB41}":{type:3,data:"M0,0.3333 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB42}":{type:3,data:"M0,0.3333 L1,0 L1,1 L0,1 Z"},"\u{1FB43}":{type:3,data:"M0,0.6667 L0.5,0 L1,0 L1,1 L0,1 Z"},"\u{1FB44}":{type:3,data:"M0,0.6667 L1,0 L1,1 L0,1 Z"},"\u{1FB45}":{type:3,data:"M0,1 L0.5,0 L1,0 L1,1 Z"},"\u{1FB46}":{type:3,data:"M0,0.6667 L1,0.3333 L1,1 L0,1 Z"},"\u{1FB47}":{type:3,data:"M0.5,1 L1,0.6667 L1,1 Z"},"\u{1FB48}":{type:3,data:"M0,1 L1,0.6667 L1,1 Z"},"\u{1FB49}":{type:3,data:"M0.5,1 L1,0.3333 L1,1 Z"},"\u{1FB4A}":{type:3,data:"M0,1 L1,0.3333 L1,1 Z"},"\u{1FB4B}":{type:3,data:"M0.5,1 L1,0 L1,1 Z"},"\u{1FB4C}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4D}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.3333 Z"},"\u{1FB4E}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB4F}":{type:3,data:"M0,0 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB50}":{type:3,data:"M0.5,0 L0,0 L0,1 L1,1 Z"},"\u{1FB51}":{type:3,data:"M0,0.3333 L0,1 L1,1 L1,0.6667 Z"},"\u{1FB52}":{type:3,data:"M0,0.6667 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB53}":{type:3,data:"M0,0.6667 L1,1 L1,0 L0,0 Z"},"\u{1FB54}":{type:3,data:"M0,0.3333 L0.5,1 L1,1 L1,0 L0,0 Z"},"\u{1FB55}":{type:3,data:"M0,0.3333 L1,1 L1,0 L0,0 Z"},"\u{1FB56}":{type:3,data:"M0,0 L0.5,1 L1,1 L1,0 Z"},"\u{1FB57}":{type:3,data:"M0,0.3333 L0,0 L0.5,0 Z"},"\u{1FB58}":{type:3,data:"M0,0.3333 L0,0 L1,0 Z"},"\u{1FB59}":{type:3,data:"M0,0.6667 L0,0 L0.5,0 Z"},"\u{1FB5A}":{type:3,data:"M0,0.6667 L0,0 L1,0 Z"},"\u{1FB5B}":{type:3,data:"M0,1 L0,0 L0.5,0 Z"},"\u{1FB5C}":{type:3,data:"M0,0.6667 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB5D}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5E}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.6667 Z"},"\u{1FB5F}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB60}":{type:3,data:"M0,1 L0,0 L1,0 L1,0.3333 Z"},"\u{1FB61}":{type:3,data:"M0.5,1 L0,1 L0,0 L1,0 Z"},"\u{1FB62}":{type:3,data:"M0.5,0 L1,0 L1,0.3333 Z"},"\u{1FB63}":{type:3,data:"M0,0 L1,0 L1,0.3333 Z"},"\u{1FB64}":{type:3,data:"M0.5,0 L1,0 L1,0.6667 Z"},"\u{1FB65}":{type:3,data:"M0,0 L1,0 L1,0.6667 Z"},"\u{1FB66}":{type:3,data:"M0.5,0 L1,0 L1,1 Z"},"\u{1FB67}":{type:3,data:"M0,0.3333 L1,0.6667 L1,0 L0,0 Z"},"\u{1FB68}":{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0.5,0.5 Z"},"\u{1FB69}":{type:3,data:"M0,0 L0.5,0.5 L1,0 L1,1 L0,1 Z"},"\u{1FB6A}":{type:3,data:"M0,0 L1,0 L0.5,0.5 L1,1 L0,1 Z"},"\u{1FB6B}":{type:3,data:"M0,0 L1,0 L1,1 L0.5,0.5 L0,1 Z"},"\u{1FB6C}":{type:3,data:"M0,0 L0.5,0.5 L0,1 Z"},"\u{1FB6D}":{type:3,data:"M0,0 L1,0 L0.5,0.5 Z"},"\u{1FB6E}":{type:3,data:"M1,0 L1,1 L0.5,0.5 Z"},"\u{1FB6F}":{type:3,data:"M0,1 L1,1 L0.5,0.5 Z"},"\u{1FB70}":{type:0,data:[{x:1,y:0,w:1,h:8}]},"\u{1FB71}":{type:0,data:[{x:2,y:0,w:1,h:8}]},"\u{1FB72}":{type:0,data:[{x:3,y:0,w:1,h:8}]},"\u{1FB73}":{type:0,data:[{x:4,y:0,w:1,h:8}]},"\u{1FB74}":{type:0,data:[{x:5,y:0,w:1,h:8}]},"\u{1FB75}":{type:0,data:[{x:6,y:0,w:1,h:8}]},"\u{1FB76}":{type:0,data:[{x:0,y:1,w:8,h:1}]},"\u{1FB77}":{type:0,data:[{x:0,y:2,w:8,h:1}]},"\u{1FB78}":{type:0,data:[{x:0,y:3,w:8,h:1}]},"\u{1FB79}":{type:0,data:[{x:0,y:4,w:8,h:1}]},"\u{1FB7A}":{type:0,data:[{x:0,y:5,w:8,h:1}]},"\u{1FB7B}":{type:0,data:[{x:0,y:6,w:8,h:1}]},"\u{1FB7C}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB7D}":{type:0,data:[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7E}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}]},"\u{1FB7F}":{type:0,data:[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}]},"\u{1FB80}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB81}":{type:0,data:[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}]},"\u{1FB82}":{type:0,data:[{x:0,y:0,w:8,h:2}]},"\u{1FB83}":{type:0,data:[{x:0,y:0,w:8,h:3}]},"\u{1FB84}":{type:0,data:[{x:0,y:0,w:8,h:5}]},"\u{1FB85}":{type:0,data:[{x:0,y:0,w:8,h:6}]},"\u{1FB86}":{type:0,data:[{x:0,y:0,w:8,h:7}]},"\u{1FB87}":{type:0,data:[{x:6,y:0,w:2,h:8}]},"\u{1FB88}":{type:0,data:[{x:5,y:0,w:3,h:8}]},"\u{1FB89}":{type:0,data:[{x:3,y:0,w:5,h:8}]},"\u{1FB8A}":{type:0,data:[{x:2,y:0,w:6,h:8}]},"\u{1FB8B}":{type:0,data:[{x:1,y:0,w:7,h:8}]},"\u{1FB8C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},"\u{1FB8D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0.5,0 L1,0 L1,1 L0.5,1 Z"},"\u{1FB8E}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},"\u{1FB8F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},"\u{1FB90}":{type:1,data:[[0,1],[1,0]]},"\u{1FB91}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0.5 L1,0.5 L1,1 L0,1 Z"},{type:0,data:[{x:0,y:0,w:8,h:4}]}],"\u{1FB92}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L1,0 L1,0.5 L0,0.5 Z"},{type:0,data:[{x:0,y:4,w:8,h:4}]}],"\u{1FB94}":[{type:1,data:[[0,1],[1,0]],clipPath:"M0,0 L0.5,0 L0.5,1 L0,1 Z"},{type:0,data:[{x:4,y:0,w:4,h:8}]}],"\u{1FB95}":{type:0,data:[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}]},"\u{1FB96}":{type:0,data:[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}]},"\u{1FB97}":{type:0,data:[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},"\u{1FB98}":{type:2,data:"M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25",strokeWidth:1},"\u{1FB99}":{type:2,data:"M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75",strokeWidth:1},"\u{1FB9A}":{type:5,data:{d:"M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0",type:0}},"\u{1FB9B}":{type:5,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1",type:0}},"\u{1FB9C}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L0,1 Z"},"\u{1FB9D}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,0 L1,1 Z"},"\u{1FB9E}":{type:1,data:[[1,0],[0,1]],clipPath:"M1,0 L1,1 L0,1 Z"},"\u{1FB9F}":{type:1,data:[[1,0],[0,1]],clipPath:"M0,0 L1,1 L0,1 Z"},"\u{1FBA0}":{type:2,data:"M.5,0 L0,.5",strokeWidth:1},"\u{1FBA1}":{type:2,data:"M.5,0 L1,.5",strokeWidth:1},"\u{1FBA2}":{type:2,data:"M0,.5 L.5,1",strokeWidth:1},"\u{1FBA3}":{type:2,data:"M1,.5 L.5,1",strokeWidth:1},"\u{1FBA4}":{type:2,data:"M.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBA5}":{type:2,data:"M.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBA6}":{type:2,data:"M0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBA7}":{type:2,data:"M0,.5 L.5,0 L1,.5",strokeWidth:1},"\u{1FBA8}":{type:2,data:"M.5,0 L0,.5 M1,.5 L.5,1",strokeWidth:1},"\u{1FBA9}":{type:2,data:"M.5,0 L1,.5 M0,.5 L.5,1",strokeWidth:1},"\u{1FBAA}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5",strokeWidth:1},"\u{1FBAB}":{type:2,data:"M.5,0 L0,.5 L.5,1 L1,.5",strokeWidth:1},"\u{1FBAC}":{type:2,data:"M0,.5 L.5,0 L1,.5 L.5,1",strokeWidth:1},"\u{1FBAD}":{type:2,data:"M1,.5 L.5,0 L0,.5 L.5,1",strokeWidth:1},"\u{1FBAE}":{type:2,data:"M.5,0 L1,.5 L.5,1 L0,.5 Z",strokeWidth:1},"\u{1FBAF}":{type:2,data:"M0,.5 L1,.5 M.5,.35 L.5,.65",strokeWidth:1},"\u{1FBB0}":{type:3,data:"M0.1,0.2 L0.1,.8 L.4,.6 L.9,0.6 Z",scaleType:1},"\u{1FBB1}":{type:4,data:{d:"M.1,.525 L.35,.675 L.9,.35",type:1},scaleType:1},"\u{1FBB2}":{type:3,data:"M.29,.27 L0.13,.56 L.22,.59 L.35,0.35 L.67,.35 L.57,.57 L.71,.76 L.22,.76 L.42,1 L.53,.98 L.43,.86 L.9,.86 L.71,.6 L1,.6 L1,.52 L.83,.52 L.92,.36 L1,.36 L1,.27Z M.99,.13 A.12,.12,0,1,1,.75,.13 A.12,.12,0,1,1,.99,.13"},"\u{1FBB3}":{type:3,data:"M0,.27 L.3,.27 L.55,.12 L.63,.18 L.33,.36 L0,.36 M0,.52 L.33,.52 L.59,.89 L.73,.89 L.73,.98 L.53,.98 L.28,.6 L0,.6"},"\u{1FBB4}":{type:4,data:{d:"M.15,.55 L.5,.4 L.5,.45 L.65,.45 L.65,.35 L.85,.35 L.85,.625 L.5,.625 L.5,.7 Z",type:0},scaleType:1},"\u{1FBB5}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.15,.5 L.5,.35 L.5,.425 L.85,.425 L.85,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB6}":[{type:5,data:{d:"M0,0 L1,0 L1,.0625 L0,.0625 Z M0,.9375 L1,.9375 L1,1 L0,1 Z",type:0}},{type:5,data:{d:"M.85,.5 L.5,.35 L.5,.425 L.15,.425 L.15,.575 L.5,.575 L.5,.65 Z",type:0},scaleType:1}],"\u{1FBB7}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.675 L.2,.5 L.35,.5 L.35,.325 L.65,.325 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB8}":[{type:5,data:{d:"M.875,0 L1,0 L1,1 L.875,1 Z",type:0}},{type:5,data:{d:"M.5,.325 L.2,.5 L.35,.5 L.35,.675 L.65,.675 L.65,.5 L.8,.5 Z",type:0},scaleType:1}],"\u{1FBB9}":{type:5,data:{d:"M1,.89 L.11,.89 L.11,.37 L.36,.12 L.74,.12 L.96,.34 L1,.34 L1,.405 L.92,.405 L.69,.185 L.41,.185 L.21,.42 L.21,.825 L1,.825 Z",type:0}},"\u{1FBBA}":{type:5,data:{d:"M0,.89 L0,.825 L.78,.825 L.78,.53 L.7,.415 L0,.415 L0,.35 L.75,.35 L.88,.48 L.88,.89 Z",type:0}},"\u{1FBBB}":{type:5,data:{d:"M.31,.275 L.44,.275 L.44,.47 L.05,.47 L.05,.405 L.31,.405 Z M.56,.275 L.69,.275 L.69,.405 L.95,.405 L.95,.47 L.56,.47 Z M.05,.53 L.44,.53 L.44,.725 L.31,.725 L.31,.595 L.05,.595 Z M.56,.53 L.95,.53 L.95,.595 L.69,.595 L.69,.725 L.56,.725 Z",type:0},scaleType:1},"\u{1FBBC}":[{type:3,data:"M0,0 L1,0 L1,1 L0,1 L0,.935 L.885,.935 L.885,.065 L0,.065 Z"},{type:3,data:"M.67,.5 A.17,.085,0,1,1,.33,.5 A.17,.085,0,1,1,.67,.5",scaleType:1}],"\u{1FBBD}":{type:4,data:{d:"M0,0 L.5,.5 L1,0 L1,1 L.5,.5 L0,1 Z",type:1}},"\u{1FBBE}":{type:4,data:{d:"M1,.5 L.5,1",type:1}},"\u{1FBBF}":{type:4,data:{d:"M.5,0 L1,.5 L.5,1 L0,.5 Z",type:1}},"\u{1FBC0}":{type:3,data:"M.16,.445 A.02,.01,0,0,1,.39,.33 L.5,.375 L.61,.33 A.02,.01,0,0,1,.84,.445 L.75,.5 L.84,.555 A.02,.01,0,0,1,.61,.67 L.5,.625 L.39,.67 A.02,.01,0,0,1,.16,.555 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",scaleType:1},"\u{1FBC1}":{type:3,data:"M0.142308,0.924 Q0.130770,0.924,0.120193,0.922 T0.101924,0.916 T0.089424,0.9065 T0.084616,0.895 V0.025 Q0.084616,0.019,0.089424,0.0135 T0.101924,0.004 T0.120193,-0.002 T0.142308,-0.004 Q0.150000,-0.004,0.157693,-0.003 T0.173078,0.000 L0.430770,0.085 Q0.451924,0.059,0.494232,0.041 T0.587501,0.013 T0.692309,-0.0005 T0.800001,-0.004 H1 V0.055 H0.800001 Q0.771155,0.055,0.742309,0.056 T0.685578,0.060 T0.630770,0.0685 T0.580770,0.0825 T0.543270,0.1045 T0.528847,0.133 V0.647 H0.530770 Q0.596155,0.645,0.659616,0.638 T0.782693,0.6165 T0.893270,0.5805 T1,0.531 V0.623 Q0.934616,0.644,0.880770,0.6595 T0.769232,0.685 T0.650963,0.700 T0.528847,0.707 V0.787 Q0.528847,0.802,0.543270,0.8155 T0.580770,0.8375 T0.630770,0.8515 T0.685578,0.860 T0.742309,0.864 T0.800001,0.865 H1 V0.924 H0.800001 Q0.746155,0.924,0.692309,0.9205 T0.587501,0.907 T0.494232,0.879 T0.430770,0.835 L0.173078,0.920 Q0.165386,0.922,0.157693,0.923 T0.142308,0.924 Z M0.2,0.841 L0.415385,0.770 V0.150 L0.2,0.079 V0.841 Z M1,0.698 Q0.973077,0.694,0.969231,0.6885 T0.965385,0.677 Q0.965385,0.672,0.969231,0.6665 T1,0.657 V0.698 Z"},"\u{1FBC2}":{type:3,data:"M0,0.623 V0.531 Q0.044231,0.512,0.0625,0.4905 T0.092308,0.4465 T0.108654,0.4005 T0.113462,0.354 V0.351 Q0.113462,0.345,0.117308,0.3395 T0.129808,0.3305 T0.148846,0.3245 T0.171154,0.323 H0.501923 Q0.532692,0.323,0.563462,0.3185 T0.617308,0.303 T0.651923,0.2765 T0.663462,0.2435 V0.241 Q0.663462,0.219,0.657692,0.197 T0.639423,0.1545 T0.604808,0.115 T0.549615,0.082 T0.475577,0.0615 T0.392308,0.055 H0 V-0.004 H1 V0.055 H0.667308 Q0.694231,0.071,0.713462,0.09 T0.746154,0.129 T0.766346,0.1705 T0.775,0.213 H1 V0.316 Q0.971154,0.305,0.955769,0.2965 T0.920192,0.2825 T0.876923,0.2745 T0.830769,0.272 H0.773077 Q0.767308,0.297,0.743269,0.319 T0.680769,0.355 T0.595192,0.375 T0.501923,0.381 H0.227308 Q0.223462,0.415,0.211,0.4485 T0.172692,0.5135 T0.108269,0.573 T0,0.623 Z M0.611538,0.924 H0 V0.865 H0.611538 Q0.642308,0.865,0.673077,0.8605 T0.727885,0.845 T0.762308,0.8185 T0.773077,0.787 V0.785 Q0.773077,0.769,0.762308,0.7535 T0.727885,0.727 T0.673077,0.7115 T0.611538,0.707 H0.059615 Q0.048077,0.707,0.037115,0.7045 T0,0.694 V0.653 Q0.026923,0.648,0.037115,0.646 T0.059615,0.644 H0.722692 Q0.753462,0.644,0.784231,0.6395 T0.839038,0.624 T0.873846,0.5975 T0.884615,0.566 V0.564 Q0.884615,0.548,0.873846,0.532 T0.839038,0.5055 T0.784231,0.49 T0.722692,0.4855 H0.392308 Q0.380769,0.4855,0.370192,0.483 T0.351923,0.4765 T0.339423,0.467 T0.334615,0.4555 T0.339423,0.444 T0.351923,0.4345 T0.370192,0.428 T0.392308,0.4255 H0.832692 Q0.855769,0.4255,0.878846,0.4235 T0.922115,0.416 T0.957692,0.4015 T1,0.3815 V0.4685 Q0.975,0.4705,0.966346,0.4725 T0.95,0.4765 Q0.961538,0.4835,0.969231,0.4915 T1,0.5075 V0.6205 Q0.965385,0.6455,0.926923,0.665 T0.84,0.6935 Q0.866923,0.7115,0.877596,0.7345 T0.894231,0.7855 V0.787 Q0.894231,0.815,0.876923,0.8425 T0.820192,0.8895 T0.726923,0.916 T0.611538,0.924 Z"},"\u{1FBC3}":{type:3,data:"M0,0.473 V0.386 Q0.025490,0.378,0.028431,0.3695 T0.031373,0.352 V0.35 Q0.031373,0.342,0.028431,0.333 T0,0.316 V0.213 H0.652941 Q0.684314,0.213,0.715686,0.2085 T0.770588,0.193 T0.804902,0.1665 T0.815686,0.135 V0.133 Q0.815686,0.117,0.804902,0.101 T0.770588,0.0745 T0.715686,0.0595 T0.652941,0.055 H0 V-0.004 H0.652941 Q0.707843,-0.004,0.761765,0.004 T0.856863,0.031 T0.915686,0.0775 T0.933333,0.133 V0.135 Q0.933333,0.163,0.915686,0.1905 T0.856863,0.237 T0.761765,0.264 T0.652941,0.272 H0.109804 Q0.131373,0.29,0.140196,0.31 T0.149020,0.35 V0.352 Q0.149020,0.37,0.142157,0.388 T0.118627,0.4225 T0.076471,0.452 T0,0.473 Z M0,0.625 V0.513 Q0.029412,0.526,0.032353,0.54 T0.035294,0.568 V0.57 Q0.035294,0.584,0.032353,0.598 T0,0.625 Z"},"\u{1FBC4}":{type:3,data:"M0.019231,1.085 V-0.165 H0.980769 V1.085 H0.019231 Z M0.446154,0.527 H0.553846 Q0.553846,0.511,0.5625,0.495 T0.591346,0.466 T0.631731,0.4405 T0.666346,0.4135 T0.688462,0.3825 T0.696154,0.35 Q0.696154,0.33,0.682692,0.311 T0.641346,0.2785 T0.575,0.259 T0.498077,0.253 T0.421154,0.259 T0.356731,0.279 T0.315385,0.3125 T0.301923,0.352 V0.357 H0.409615 V0.354 Q0.409615,0.345,0.414423,0.335 T0.431731,0.318 T0.4625,0.3075 T0.498077,0.304 Q0.517308,0.304,0.534615,0.307 T0.564423,0.3165 T0.582692,0.332 T0.588462,0.35 Q0.588462,0.366,0.572115,0.3805 T0.535577,0.4075 T0.496154,0.4335 T0.465385,0.4625 T0.45,0.4945 T0.446154,0.527 Z M0.5,0.667 Q0.519231,0.667,0.5375,0.664 T0.569231,0.654 T0.588462,0.637 T0.594231,0.617 T0.588462,0.5975 T0.569231,0.581 T0.5375,0.571 T0.5,0.568 T0.4625,0.571 T0.430769,0.581 T0.411538,0.5975 T0.405769,0.617 T0.411538,0.637 T0.430769,0.654 T0.4625,0.664 T0.5,0.667 Z",scaleType:1},"\u{1FBC5}":{type:5,data:{d:"M.44,.425 L.44,.51 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.51 L.56,.425 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC6}":{type:5,data:{d:"M.44,.425 L.44,.46 L.23,.385 L.19,.43 L.44,.51 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.61,.735 L.7,.71 L.56,.56 L.56,.51 L.81,.43 L.77,.385 L.56,.46 L.56,.425 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC7}":{type:5,data:{d:"M.44,.425 L.44,.56 L.29,.71 L.38,.735 L.5,.605 L.74,.72 L.81,.685 L.56,.56 L.56,.425 Z M.18,.53 L.23,.575 L.81,.43 L.77,.385 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC8}":{type:5,data:{d:"M.44,.425 L.44,.56 L.19,.685 L.26,.72 L.5,.605 L.62,.735 L.71,.71 L.56,.56 L.56,.425 Z M.23,.385 L.18,.43 L.77,.575 L.81,.53 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBC9}":{type:5,data:{d:"M.44,.425 L.45,.495 L.15,.645 L.34,.645 L.34,.7 L.44,.7 L.44,.645 L.56,.645 L.56,.7 L.66,.7 L.66,.645 L.84,.645 L.54,.495 L.56,.425 Z M.39,.6 L.5,.55 L.60,.6 Z M.17,.46 L.17,.51 L.83,.51 L.83,.46 Z M.67,.35 C.67,.303,.594,.265,.5,.265 C.406,.265,.33,.303,.33,.35 C.33,.397,.406,.435,.5,.435 C.594,.435,.67,.397,.67,.35 Z M.56,.35 C.56,.3665,.533,.38,.5,.38 C.467,.38,.44,.3665,.44,.35 C.44,.3335,.467,.32,.5,.32 C.533,.32,.56,.3335,.56,.35 Z",type:0},scaleType:1},"\u{1FBCA}":{type:5,data:{d:"M.26,.375 L.5,.255 L.74,.375 L.74,.665 L.5,.55 L.26,.665 Z M.37,.395 L.37,.54 L.5,.475 L.63,.54 L.63,.395 L.5,.33 Z",type:0},scaleType:1},"\u{1FBCB}":{type:5,data:{d:"M.09,.41 L.32,.295 L.5,.375 L.68,.295 L.91,.41 L.75,.5 L.91,.59 L.68,.705 L.5,.625 L.32,.705 L.09,.59 L.25,.5 Z M.24,.41 L.39,.5 L.24,.59 L.32,.63 L.5,.555 L.68,.63 L.76,.59 L.61,.5 L.76,.41 L.68,.37 L.5,.445 L.32,.37 Z",type:0},scaleType:1},"\u{1FBCC}":{type:5,data:{d:"M.55,.305 L.88,.305 L.88,.355 L.65,.355 L.65,.47 L.88,.47 L.88,.52 L.55,.52 Z",type:0},scaleType:1},"\u{1FBCD}":{type:5,data:{d:"M.38,.39 L.5,.33 L.62,.39 L.62,.53 L.5,.47 L.38,.53 Z",type:0},scaleType:1},"\u{1FBCE}":{type:3,data:"M0,0 L0.6667,0 L0.6667,1 L0,1 Z"},"\u{1FBCF}":{type:3,data:"M0,0 L0.3333,0 L0.3333,1 L0,1 Z"},"\u{1FBD0}":{type:2,data:"M1,.5 L0,1",strokeWidth:1},"\u{1FBD1}":{type:2,data:"M1,0 L0,.5",strokeWidth:1},"\u{1FBD2}":{type:2,data:"M0,0 L1,.5",strokeWidth:1},"\u{1FBD3}":{type:2,data:"M0,.5 L1,1",strokeWidth:1},"\u{1FBD4}":{type:2,data:"M0,0 L.5,1",strokeWidth:1},"\u{1FBD5}":{type:2,data:"M.5,0 L1,1",strokeWidth:1},"\u{1FBD6}":{type:2,data:"M1,0 L.5,1",strokeWidth:1},"\u{1FBD7}":{type:2,data:"M.5,0 L0,1",strokeWidth:1},"\u{1FBD8}":{type:2,data:"M0,0 L.5,.5 L1,0",strokeWidth:1},"\u{1FBD9}":{type:2,data:"M1,0 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDA}":{type:2,data:"M0,1 L.5,.5 L1,1",strokeWidth:1},"\u{1FBDB}":{type:2,data:"M0,0 L.5,.5 L0,1",strokeWidth:1},"\u{1FBDC}":{type:2,data:"M0,0 L.5,1 L1,0",strokeWidth:1},"\u{1FBDD}":{type:2,data:"M1,0 L0,.5 L1,1",strokeWidth:1},"\u{1FBDE}":{type:2,data:"M0,1 L.5,0 L1,1",strokeWidth:1},"\u{1FBDF}":{type:2,data:"M0,0 L1,.5 L0,1",strokeWidth:1},"\u{1FBE0}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0",type:1}},"\u{1FBE1}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1",type:1}},"\u{1FBE2}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1",type:1}},"\u{1FBE3}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1",type:1}},"\u{1FBE4}":{type:0,data:[{x:2,y:0,w:4,h:4}]},"\u{1FBE5}":{type:0,data:[{x:2,y:4,w:4,h:4}]},"\u{1FBE6}":{type:0,data:[{x:0,y:2,w:4,h:4}]},"\u{1FBE7}":{type:0,data:[{x:4,y:2,w:4,h:4}]},"\u{1FBE8}":{type:5,data:{d:"M0,0 C0,.276,.224,.5,.5,.5 C.776,.5,1,.276,1,0 Z",type:0}},"\u{1FBE9}":{type:5,data:{d:"M1,0 C.724,0,.5,.224,.5,.5 C.5,.776,.724,1,1,1 Z",type:0}},"\u{1FBEA}":{type:5,data:{d:"M0,1 C0,.724,.224,.5,.5,.5 C.776,.5,1,.724,1,1 Z",type:0}},"\u{1FBEB}":{type:5,data:{d:"M0,0 C.276,0,.5,.224,.5,.5 C.5,.776,.276,1,0,1 Z",type:0}},"\u{1FBEC}":{type:5,data:{d:"M1,0 L.5,0 C.5,.276,.724,.5,1,.5 Z",type:0}},"\u{1FBED}":{type:5,data:{d:"M0,1 L.5,1 C.5,.724,.276,.5,0,.5 Z",type:0}},"\u{1FBEE}":{type:5,data:{d:"M1,1 L1,.5 C.724,.5,.5,.724,.5,1 Z",type:0}},"\u{1FBEF}":{type:5,data:{d:"M0,0 L0,.5 C.276,.5,.5,.276,.5,0 Z",type:0}},"\u{1FBF0}":{type:5,data:{d:o0(126),type:0},scaleType:1},"\u{1FBF1}":{type:5,data:{d:o0(48),type:0},scaleType:1},"\u{1FBF2}":{type:5,data:{d:o0(109),type:0},scaleType:1},"\u{1FBF3}":{type:5,data:{d:o0(121),type:0},scaleType:1},"\u{1FBF4}":{type:5,data:{d:o0(51),type:0},scaleType:1},"\u{1FBF5}":{type:5,data:{d:o0(91),type:0},scaleType:1},"\u{1FBF6}":{type:5,data:{d:o0(95),type:0},scaleType:1},"\u{1FBF7}":{type:5,data:{d:o0(114),type:0},scaleType:1},"\u{1FBF8}":{type:5,data:{d:o0(127),type:0},scaleType:1},"\u{1FBF9}":{type:5,data:{d:o0(123),type:0},scaleType:1},"\u{1FBFA}":{type:5,data:{d:"M.5,.175 C.2,.175,.15,.305,.15,.435 L.05,.63 L.35,.63 C.35,.682,.42,.76,.5,.76 C.58,.76,.65,.682,.65,.63 L.95,.63 L.85,.435 C.85,.305,.8,.175,.5,.175 Z",type:0},scaleType:1},...Object.fromEntries(Array.from({length:256},(i,e)=>[String.fromCodePoint(10240+e),{type:6,data:e}]))},$e=new Set([9617,9618,9619,129932,129933,129934,129935,129936,129937,129938,129940,129948,129949,129950,129951]);function f(i){return{type:2,data:()=>{let e=[],r=[.375,.25,.375],s=[0,3/8,5/8];for(let a=0;a<3;a++){let o=i>>a*2&1,n=i>>a*2+1&1;o&&n?e.push(`M0,${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L0,${s[a]+r[a]} Z`):o?e.push(`M0,${s[a]} L${.5},${s[a]} L${.5},${s[a]+r[a]} L0,${s[a]+r[a]} Z`):n&&e.push(`M${.5},${s[a]} L1,${s[a]} L1,${s[a]+r[a]} L${.5},${s[a]+r[a]} Z`)}return e.join(" ")}}}function o0(i){let e=[];if(i&64){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.2125} L${Q+.075},${.175} L${m-.075},${.175} L${m},${.2125} L${m-.075},${.25} L${Q+.075},${.25} Z`)}if(i&32){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.175+.075+.015,m=.5-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&16){let c=.7999999999999999,u=.95-.15/2,C=.95,Q=.5+.015,m=.825-.075-.015;e.push(`M${u},${Q} L${C},${Q+.0375} L${C},${m-.0375} L${u},${m} L${c},${m-.0375} L${c},${Q+.0375} Z`)}if(i&8){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.7875} L${Q+.075},${.75} L${m-.075},${.75} L${m},${.7875} L${m-.075},${.825} L${Q+.075},${.825} Z`)}if(i&4&&e.push(`M${.125},${.515} L${.2},${.515+.0375} L${.2},${.735-.0375} L${.125},${.735} L${.05},${.735-.0375} L${.05},${.515+.0375} Z`),i&2&&e.push(`M${.125},${.265} L${.2},${.265+.0375} L${.2},${.485-.0375} L${.125},${.485} L${.05},${.485-.0375} L${.05},${.265+.0375} Z`),i&1){let Q=.21500000000000002,m=.95-.15-.015;e.push(`M${Q},${.5} L${Q+.075},${.4625} L${m-.075},${.4625} L${m},${.5} L${m-.075},${.5375} L${Q+.075},${.5375} Z`)}return e.join(" ")}var k=0,H=0,T0=!1,_0=!1,$0=!1,z,x0=0,G0=class{constructor(e,t,r,s,a,o){this._terminal=e;this._optionService=t;this._selectionRenderModel=r;this._decorationService=s;this._coreBrowserService=a;this._themeService=o;this.result={fg:0,bg:0,ext:0}}resolve(e,t,r,s,a){this.result.bg=e.bg,this.result.fg=e.fg,this.result.ext=e.bg&268435456?e.extended.ext:0,H=0,k=0,_0=!1,T0=!1,$0=!1,z=this._themeService.colors,x0=0;let o=e.getCode();if(o!==0&&e.extended.underlineStyle===4){let n=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));x0=t*s%(Math.round(n)*2)}if(x0===0&&$e.has(o)&&(x0=t*s%2*2+r*a%2),this._decorationService.forEachDecorationAtCell(t,r,"bottom",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),$0=this._selectionRenderModel.isCellSelected(this._terminal,t,r),$0){if(this.result.fg&67108864||(this.result.bg&50331648)!==0){if(this.result.fg&67108864)switch(this.result.fg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:H=(this.result.fg&16777215)<<8|255;break;case 0:default:H=this._themeService.colors.foreground.rgba}else switch(this.result.bg&50331648){case 16777216:case 33554432:H=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:H=(this.result.bg&16777215)<<8|255;break}H=d0.blend(H,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}else H=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(_0=!0,z.selectionForeground&&(k=z.selectionForeground.rgba>>8&16777215,T0=!0),V0(e.getCode())){if(this.result.fg&67108864&&(this.result.bg&50331648)===0)k=(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(this.result.fg&67108864)switch(this.result.bg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:k=(this.result.bg&16777215)<<8|255;break}else switch(this.result.fg&50331648){case 16777216:case 33554432:k=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:k=(this.result.fg&16777215)<<8|255;break;case 0:default:k=this._themeService.colors.foreground.rgba}k=d0.blend(k,(this._coreBrowserService.isFocused?z.selectionBackgroundOpaque:z.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}T0=!0}}this._decorationService.forEachDecorationAtCell(t,r,"top",n=>{n.backgroundColorRGB&&(H=n.backgroundColorRGB.rgba>>8&16777215,_0=!0),n.foregroundColorRGB&&(k=n.foregroundColorRGB.rgba>>8&16777215,T0=!0)}),_0&&($0?H=e.bg&-16777216&-134217729|H|50331648:H=e.bg&-16777216|H|50331648),T0&&(k=e.fg&-16777216&-67108865|k|50331648),this.result.fg&67108864&&(_0&&!T0&&((this.result.bg&50331648)===0?k=this.result.fg&-134217728|z.background.rgba>>8&16777215&16777215|50331648:k=this.result.fg&-134217728|this.result.bg&67108863,T0=!0),!_0&&T0&&((this.result.fg&50331648)===0?H=this.result.bg&-67108864|z.foreground.rgba>>8&16777215&16777215|50331648:H=this.result.bg&-67108864|this.result.fg&67108863,_0=!0)),z=void 0,this.result.bg=_0?H:this.result.bg,this.result.fg=T0?k:this.result.fg,this.result.ext&=536870911,this.result.ext|=x0<<29&3758096384}};var Ke=.5,K0=Be||we?"bottom":"ideographic";function qe(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=Ve[e];if(p){let c=Array.isArray(p)?p:[p];for(let u of c)Pt(i,u,t,r,s,a,o,n,L,T,h,d,_);return!0}return!1}function Pt(i,e,t,r,s,a,o,n,L,T,h,d,_=0){let p=s,c=a,u=t,C=r;switch(e.scaleType===1&&(p=o,c=n,u=t+(s-o)/2,C=r+(a-n)/2),e.clipPath&&(i.save(),Vt(i,e.clipPath,u,C,p,c)),e.type){case 0:Bt(i,e.data,u,C,p,c);break;case 1:Ut(i,e.data,u,C,p,c,_);break;case 2:Wt(i,e.data,u,C,p,c,T,h,e.strokeWidth);break;case 3:Ht(i,e.data,u,C,p,c,T,e.strokeWidth);break;case 4:Zt(i,e.data,u,C,p,c,T,d);break;case 5:$t(i,e.data,u,C,p,c,L,T,h);break;case 6:wt(i,e.data,u,C,p,c);break}e.clipPath&&i.restore()}function Bt(i,e,t,r,s,a){for(let o=0;o{let F=parseFloat(m);return R%2===0?t+F*s:r+F*a});if(u==="M")i.moveTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="L")i.lineTo(Q[0],Q[1]),T=Q[0],h=Q[1],d=T,_=h;else if(u==="Q")i.quadraticCurveTo(Q[0],Q[1],Q[2],Q[3]),d=Q[0],_=Q[1],T=Q[2],h=Q[3];else if(u==="T"){let m,R;p==="Q"||p==="T"?(m=2*T-d,R=2*h-_):(m=T,R=h),i.quadraticCurveTo(m,R,Q[0],Q[1]),d=m,_=R,T=Q[0],h=Q[1]}else u==="C"&&(i.bezierCurveTo(Q[0],Q[1],Q[2],Q[3],Q[4],Q[5]),d=Q[2],_=Q[3],T=Q[4],h=Q[5]);p=u}n!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*n,i.stroke()):i.fill()}function kt(i,e,t,r,s,a,o,n,L,T){if(r===0||s===0){i.lineTo(L,T);return}r=Math.abs(r),s=Math.abs(s);let h=Math.cos(a),d=Math.sin(a),_=(e-L)/2,p=(t-T)/2,c=h*_+d*p,u=-d*_+h*p,C=r*r,Q=s*s,m=c*c,R=u*u,F=m/C+R/Q;if(F>1){let I=Math.sqrt(F);r*=I,s*=I,C=r*r,Q=s*s}let E=(C*Q-C*R-Q*m)/(C*R+Q*m);E<0&&(E=0);let y=(o===n?-1:1)*Math.sqrt(E),Y=y*(r*u/s),X=y*-(s*c/r),A0=h*Y-d*X+(e+L)/2,t0=d*Y+h*X+(t+T)/2,L0=(c-Y)/r,A=(u-X)/s,n0=(-c-Y)/r,a0=(-u-X)/s,$=Math.atan2(A,L0),q=Math.atan2(a0,n0)-$;n===0&&q>0?q-=2*Math.PI:n===1&&q<0&&(q+=2*Math.PI);let g0=$+q;i.ellipse(A0,t0,r,s,a,$,g0,n===0)}function Zt(i,e,t,r,s,a,o,n){i.save(),i.fillRect(t,r,s,a),n&&(i.fillStyle=n,i.strokeStyle=n),i.lineWidth=o,i.lineCap="square",i.beginPath();for(let L of e.d.split(" ")){let T=L[0],h=L.substring(1).split(",");if(!h[0]||!h[1]){T==="Z"&&i.closePath();continue}let d=h.map((_,p)=>{let c=parseFloat(_);return p%2===0?t+c*s:r+c*a});T==="M"?i.moveTo(d[0],d[1]):T==="L"&&i.lineTo(d[0],d[1])}e.type===1?i.stroke():i.fill(),i.restore()}var Xe=new Map;function Ut(i,e,t,r,s,a,o=0){let n=Xe.get(e);n||(n=new Map,Xe.set(e,n));let L=i.fillStyle;if(typeof L!="string")throw new Error(`Unexpected fillStyle type "${L}"`);let T=n.get(L);if(!T){let _=e[0].length,p=e.length,c=i.canvas.ownerDocument.createElement("canvas");c.width=_,c.height=p;let u=g(c.getContext("2d")),C=new ImageData(_,p),Q,m,R,F;if(L.startsWith("#"))Q=parseInt(L.slice(1,3),16),m=parseInt(L.slice(3,5),16),R=parseInt(L.slice(5,7),16),F=L.length>7&&parseInt(L.slice(7,9),16)||1;else if(L.startsWith("rgba"))[Q,m,R,F]=L.substring(5,L.length-1).split(",").map(E=>parseFloat(E));else throw new Error(`Unexpected fillStyle color format "${L}" when drawing pattern glyph`);for(let E=0;E>1&1,d=o&1;h!==0||d!==0?T.setTransform(new DOMMatrix().translateSelf(-h,-d)):T.setTransform(new DOMMatrix),i.fillStyle=T,i.fillRect(t,r,s,a)}function Wt(i,e,t,r,s,a,o,n,L){i.save(),i.beginPath(),i.rect(t,r,s,a),i.clip(),i.beginPath();let T;if(typeof e=="function"){let _=.15/a*s;T=e(.15,_)}else T=e;let h={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let d of T.split(" ")){let _=d[0];if(_==="Z"){i.closePath(),h.lastCommand=_;continue}let p=je[_];if(!p){n.error(`Could not find drawing instructions for "${_}"`);continue}let c=d.substring(1).split(",");!c[0]||!c[1]||(p(i,Je(c,s,a,t,r,!0,o,0,0,!1),h),h.lastCommand=_)}L!==void 0?(i.strokeStyle=i.fillStyle,i.lineWidth=o*L,i.stroke()):i.fill(),i.closePath(),i.restore()}function Vt(i,e,t,r,s,a){i.beginPath();for(let o of e.split(" ")){let n=o[0];if(n==="Z"){i.closePath();continue}let L=o.substring(1).split(",");if(!L[0]||!L[1])continue;let T=t+parseFloat(L[0])*s,h=r+parseFloat(L[1])*a;n==="M"?i.moveTo(T,h):n==="L"&&i.lineTo(T,h)}i.clip()}function $t(i,e,t,r,s,a,o,n,L){let T=new Path2D;T.rect(t,r,s,a),i.clip(T),i.beginPath();let h=o/12;i.lineWidth=n*h;let d={currentX:0,currentY:0,lastControlX:0,lastControlY:0,lastCommand:""};for(let _ of e.d.split(" ")){let p=_[0];if(p==="Z"){i.closePath(),d.lastCommand=p;continue}let c=je[p];if(!c){L.error(`Could not find drawing instructions for "${p}"`);continue}let u=_.substring(1).split(",");!u[0]||!u[1]||(c(i,Je(u,s,a,t,r,!1,n,(e.leftPadding??0)*(h/2),(e.rightPadding??0)*(h/2)),d),d.lastCommand=p)}e.type===1?(i.strokeStyle=i.fillStyle,i.stroke()):i.fill(),i.closePath()}function Ye(i,e,t=0){return Math.max(Math.min(i,e),t)}var je={C:(i,e,t)=>{i.bezierCurveTo(e[0],e[1],e[2],e[3],e[4],e[5]),t.lastControlX=e[2],t.lastControlY=e[3],t.currentX=e[4],t.currentY=e[5]},L:(i,e,t)=>{i.lineTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},M:(i,e,t)=>{i.moveTo(e[0],e[1]),t.lastControlX=t.currentX=e[0],t.lastControlY=t.currentY=e[1]},Q:(i,e,t)=>{i.quadraticCurveTo(e[0],e[1],e[2],e[3]),t.lastControlX=e[0],t.lastControlY=e[1],t.currentX=e[2],t.currentY=e[3]},T:(i,e,t)=>{let r,s;t.lastCommand==="Q"||t.lastCommand==="T"?(r=2*t.currentX-t.lastControlX,s=2*t.currentY-t.lastControlY):(r=t.currentX,s=t.currentY),i.quadraticCurveTo(r,s,e[0],e[1]),t.lastControlX=r,t.lastControlY=s,t.currentX=e[0],t.currentY=e[1]}};function Je(i,e,t,r,s,a,o,n=0,L=0,T=!0){let h=i.map(d=>parseFloat(d)||parseInt(d));if(h.length<2)throw new Error("Too few arguments for instruction");for(let d=0;da){s-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=a}this.clear()}},Ce=class extends X0{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Qe=class extends X0{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},et="requestIdleCallback"in globalThis?Qe:Ce;var s0=class i{constructor(){this.fg=0;this.bg=0;this.extended=new P0}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},P0=class i{constructor(e=0,t=0){this._ext=0;this._urlId=0;this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new i(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var U=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(e,t,r)=>{if(this._disposed)return M(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let a=M(()=>{let o=this._listeners.indexOf(s);o!==-1&&this._listeners.splice(o,1)});return r&&(Array.isArray(r)?r.push(a):r.add(a)),a},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:r}=this._listeners[0];t.call(r,e);return}default:{let t=this._listeners.slice();for(let{fn:r,thisArgs:s}of t)r.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},c0;(s=>{function i(a,o){return a(n=>o.fire(n))}s.forward=i;function e(a,o){return(n,L,T)=>a(h=>n.call(L,o(h)),void 0,T)}s.map=e;function t(...a){return(o,n,L)=>{let T=new R0;for(let h of a)T.add(h(d=>o.call(n,d)));return L&&(Array.isArray(L)?L.push(T):L.add(T)),T}}s.any=t;function r(a,o,n){return o(n),a(L=>o(L))}s.runAndSubscribe=r})(c0||={});var tt={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},B0=2;var w0,J=class i{constructor(e,t,r,s){this._document=e;this._config=t;this._unicodeService=r;this._logService=s;this._didWarmUp=!1;this._cacheMap=new D0;this._cacheMapCombined=new D0;this._pages=[];this._activePages=[];this._workBoundingBox={top:0,left:0,bottom:0,right:0};this._workAttributeData=new s0;this._textureSize=512;this._onAddTextureAtlasCanvas=new U;this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=new U;this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._requestClearModel=!1;this._clearModelGeneration=0;this._createNewPage(),this._tmpCanvas=it(e,this._config.deviceCellWidth*4+B0*2,this._config.deviceCellHeight+B0*2),this._tmpCtx=g(this._tmpCanvas.getContext("2d",{alpha:this._config.allowTransparency,willReadFrequently:!0}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose(),this._onRemoveTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||(this._doWarmUp(),this._didWarmUp=!0)}_doWarmUp(){let e=new et(this._logService);for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let r=this._drawToCache(t,0,0,0,!1,void 0);this._cacheMap.set(t,0,0,0,r)}})}beginFrame(){let e=this._requestClearModel;return this._requestClearModel=!1,e}clearTexture(){if(this._pages.some(e=>e._glyphs.length>0||e.currentRow.x!==0||e.currentRow.y!==0)){for(let e of this._pages)e.clear();this._activePages.length=0;for(let e of this._pages)e!==this._overflowSizePage&&this._activePages.push(e);this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1,this._requestClearModel=!0,this._clearModelGeneration++}}_createNewPage(){if(i.maxAtlasPages&&this._pages.length>=Math.max(4,i.maxAtlasPages)){let t=this._pages.filter(T=>T.canvas.width*2<=(i.maxTextureSize||4096)).sort((T,h)=>h.canvas.width!==T.canvas.width?h.canvas.width-T.canvas.width:h.percentageUsed-T.percentageUsed),r=-1,s=0;for(let T=0;TT.canvas.width!==a[0].canvas.width)){let T=new u0(this._document,this._textureSize);return this._pages.push(T),this._activePages.push(T),this._onAddTextureAtlasCanvas.fire(T.canvas),T}let o=a.map(T=>T.glyphs[0].texturePage).sort((T,h)=>T-h),n=this.pages.length-a.length,L=this._mergePages(a,n);L.version=++u0.nextVersion;for(let T=o.length-1;T>=0;T--)this._deletePage(o[T]);this.pages.push(L),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(L.canvas)}let e=new u0(this._document,this._textureSize);return this._pages.push(e),this._activePages.push(e),this._onAddTextureAtlasCanvas.fire(e.canvas),e}_mergePages(e,t){let r=e[0].canvas.width*2,s=new u0(this._document,r,e);for(let[a,o]of e.entries()){let n=a*o.canvas.width%r,L=Math.floor(a/2)*o.canvas.height;s.ctx.drawImage(o.canvas,n,L);for(let h of o.glyphs)h.texturePage=t,h.sizeClipSpace.x=h.size.x/r,h.sizeClipSpace.y=h.size.y/r,h.texturePosition.x+=n,h.texturePosition.y+=L,h.texturePositionClipSpace.x=h.texturePosition.x/r,h.texturePositionClipSpace.y=h.texturePosition.y/r;this._onRemoveTextureAtlasCanvas.fire(o.canvas);let T=this._activePages.indexOf(o);T!==-1&&this._activePages.splice(T,1)}return s}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw new Error("No color found for idx "+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,r,s){if(this._config.allowTransparency)return K;let a;switch(e){case 16777216:case 33554432:a=this._getColorFromAnsiIndex(t);break;case 50331648:let o=s0.toColorRGB(t);a=Z.toColor(o[0],o[1],o[2]);break;case 0:default:r?a=I0.opaque(this._config.colors.foreground):a=this._config.colors.background;break}return this._config.allowTransparency||(a=I0.opaque(a)),a}_getForegroundColor(e,t,r,s,a,o,n,L,T,h){let d=this._getMinimumContrastColor(e,t,r,s,a,o,n,T,L,h);if(d)return d;let _;switch(a){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&T&&o<8&&(o+=8),_=this._getColorFromAnsiIndex(o);break;case 50331648:let p=s0.toColorRGB(o);_=Z.toColor(p[0],p[1],p[2]);break;case 0:default:n?_=this._config.colors.background:_=this._config.colors.foreground}return this._config.allowTransparency&&(_=I0.opaque(_)),L&&(_=I0.multiplyOpacity(_,Ke)),_}_resolveBackgroundRgba(e,t,r){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,r,s){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&s&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;case 0:default:return r?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,r,s,a,o,n,L,T,h){if(this._config.minimumContrastRatio===1||h)return;let d=this._getContrastCache(T),_=d.getColor(e,s);if(_!==void 0)return _??void 0;let p=this._resolveBackgroundRgba(t,r,n),c=this._resolveForegroundRgba(a,o,n,L),u=d0.ensureContrastRatio(p,c,this._config.minimumContrastRatio/(T?2:1));if(!u){d.setColor(e,s,null);return}let C=Z.toColor(u>>24&255,u>>16&255,u>>8&255);return d.setColor(e,s,C),C}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(e,t,r,s,a,o){let n=typeof e=="number"?String.fromCharCode(e):e;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display="none",o.append(this._tmpCanvas));let L=Math.min(this._config.deviceCellWidth*Math.max(n.length,2)+B0*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width=b?b*2-f0:b-f0;!(f0>=b)===!1||Z0===0?(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j+Z0,G),this._tmpCtx.lineTo(Q0,G)):(this._tmpCtx.setLineDash([Math.round(b),Math.round(b)]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(j+Z0,G),this._tmpCtx.moveTo(j+Z0+b,G),this._tmpCtx.lineTo(Q0,G)),f0=We(Q0-j,b,f0);break;case 5:let vt=.6,Ot=.3,de=Q0-j,Se=Math.floor(vt*de),Re=Math.floor(Ot*de),It=de-Se-Re;this._tmpCtx.setLineDash([Se,Re,It]),this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break;case 1:default:this._tmpCtx.moveTo(j,G),this._tmpCtx.lineTo(Q0,G);break}y0||this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!n0&&this._config.fontSize>=12&&!this._config.allowTransparency&&n!==" "){this._tmpCtx.save(),this._tmpCtx.textBaseline="alphabetic";let r0=this._tmpCtx.measureText(n);if(this._tmpCtx.restore(),"actualBoundingBoxDescent"in r0&&r0.actualBoundingBoxDescent>0){this._tmpCtx.save();let y0=new Path2D;y0.rect(i0,C0-Math.ceil(b/2),this._config.deviceCellWidth*$,v0-C0+Math.ceil(b/2)),this._tmpCtx.clip(y0),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=y.css,this._tmpCtx.strokeText(n,A,A+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(Q){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),D=b%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+D),this._tmpCtx.stroke()}if(n0||this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight),n==="_"&&!this._config.allowTransparency){let b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0);if(b)for(let D=1;D<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=y.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(n,A,A+this._config.deviceCharHeight-D),b=fe(this._tmpCtx.getImageData(A,A,this._config.deviceCellWidth,this._config.deviceCellHeight),y,L0,a0),!!b);D++);}if(C){let b=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),D=this._tmpCtx.lineWidth%2===1?.5:0;this._tmpCtx.lineWidth=b,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(A,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.lineTo(A+this._config.deviceCharWidth*$,A+Math.floor(this._config.deviceCharHeight/2)-D),this._tmpCtx.stroke()}this._tmpCtx.restore();let q=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),g0;if(this._config.allowTransparency?g0=Gt(q):g0=fe(q,y,L0,a0),g0)return tt;let I=this._findGlyphBoundingBox(q,this._workBoundingBox,L,t0,n0,A),O,v;for(;;){if(this._activePages.length===0){let b=this._createNewPage();O=b,v=b.currentRow,v.height=I.size.y;break}O=this._activePages[this._activePages.length-1],v=O.currentRow;for(let b of this._activePages)I.size.y<=b.currentRow.height&&(O=b,v=b.currentRow);for(let b=this._activePages.length-1;b>=0;b--)for(let D of this._activePages[b].fixedRows)D.height<=v.height&&I.size.y<=D.height&&(O=this._activePages[b],v=D);if(I.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new u0(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._clearModelGeneration++,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),O=this._overflowSizePage,v=this._overflowSizePage.currentRow,v.x+I.size.x>=O.canvas.width&&(v.x=0,v.y+=v.height,v.height=0);break}if(v.y+I.size.y>=O.canvas.height||v.height>I.size.y+2){let b=!1;if(O.currentRow.y+O.currentRow.height+I.size.y>=O.canvas.height){let D;for(let i0 of this._activePages)if(i0.currentRow.y+i0.currentRow.height+I.size.y=i.maxAtlasPages&&v.y+I.size.y<=O.canvas.height&&v.height>=I.size.y&&v.x+I.size.x<=O.canvas.width)b=!0;else{let i0=this._createNewPage();O=i0,v=i0.currentRow,v.height=I.size.y,b=!0}}b||(O.currentRow.height>0&&O.fixedRows.push(O.currentRow),v={x:0,y:O.currentRow.y+O.currentRow.height,height:I.size.y},O.fixedRows.push(v),O.currentRow={x:0,y:v.y+v.height,height:0})}if(v.x+I.size.x<=O.canvas.width)break;v===O.currentRow?(v.x=0,v.y+=v.height,v.height=0):O.fixedRows.splice(O.fixedRows.indexOf(v),1)}return I.texturePage=this._pages.indexOf(O),I.texturePosition.x=v.x,I.texturePosition.y=v.y,I.texturePositionClipSpace.x=v.x/O.canvas.width,I.texturePositionClipSpace.y=v.y/O.canvas.height,I.sizeClipSpace.x/=O.canvas.width,I.sizeClipSpace.y/=O.canvas.height,v.height=Math.max(v.height,I.size.y),v.x+=I.size.x,O.ctx.putImageData(q,I.texturePosition.x-this._workBoundingBox.left,I.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,I.size.x,I.size.y),O.addGlyph(I),O.version=++u0.nextVersion,I}_findGlyphBoundingBox(e,t,r,s,a,o){t.top=0;let n=s?this._config.deviceCellHeight:this._tmpCanvas.height,L=s?this._config.deviceCellWidth:r,T=!1;for(let h=0;h=o;h--){for(let d=0;d=0;h--){for(let d=0;d>>24,a=e.rgba>>>16&255,o=e.rgba>>>8&255,n=t.rgba>>>24,L=t.rgba>>>16&255,T=t.rgba>>>8&255,h=Math.floor((Math.abs(s-n)+Math.abs(a-L)+Math.abs(o-T))/12),d=!0;for(let _=0;_0)return!1;return!0}function it(i,e,t){let r=i.createElement("canvas");return r.width=e,r.height=t,r}function rt(i,e,t,r,s,a,o,n,L=!0){let T={foreground:a.foreground,background:a.background,cursor:K,cursorAccent:K,selectionForeground:K,selectionBackgroundTransparent:K,selectionBackgroundOpaque:K,selectionInactiveBackgroundTransparent:K,selectionInactiveBackgroundOpaque:K,overviewRulerBorder:K,scrollbarSliderBackground:K,scrollbarSliderHoverBackground:K,scrollbarSliderActiveBackground:K,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:L,devicePixelRatio:o,deviceMaxTextureSize:n,letterSpacing:s.letterSpacing,lineHeight:s.lineHeight,deviceCellWidth:i,deviceCellHeight:e,deviceCharWidth:t,deviceCharHeight:r,fontFamily:s.fontFamily,fontSize:s.fontSize,fontWeight:s.fontWeight,fontWeightBold:s.fontWeightBold,allowTransparency:s.allowTransparency,drawBoldTextInBrightColors:s.drawBoldTextInBrightColors,minimumContrastRatio:s.minimumContrastRatio,colors:T}}function Ee(i,e){for(let t=0;t=0){if(Ee(u.config,h))return u.atlas;u.ownedBy.length===1?(u.atlas.dispose(),e0.splice(c,1)):u.ownedBy.splice(C,1);break}}for(let c=0;c{this._renderCallback(),this._animationFrame=void 0})))}_restartInterval(e=600){this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,t>0){this._restartInterval(t);return}}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let t=600-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(t);return}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},600)},e)}pause(){this.isCursorVisible=!0,this._isIdlePaused=!1,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this._resetIdleTimer(),this.restartBlinkAnimation()}_resetIdleTimer(){this._isIdlePaused=!1,this._idleTimeout&&this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_stopBlinkingDueToIdle(){this.isCursorVisible=!0,this._isIdlePaused=!0,this._blinkInterval&&(this._coreBrowserService.window.clearInterval(this._blinkInterval),this._blinkInterval=void 0),this._blinkStartTimeout&&(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),this._blinkStartTimeout=void 0),this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0),this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0,this._renderCallback()}};function ve(i,e,t){let r=new e.ResizeObserver(s=>{let a=s.find(L=>L.target===i);if(!a)return;if(!("devicePixelContentBoxSize"in a)){r?.disconnect(),r=void 0;return}let o=a.devicePixelContentBoxSize[0].inlineSize,n=a.devicePixelContentBoxSize[0].blockSize;o>0&&n>0&&t(o,n)});try{r.observe(i,{box:["device-pixel-content-box"]})}catch{r.disconnect(),r=void 0}return M(()=>r?.disconnect())}function nt(i){return i>65535?(i-=65536,String.fromCharCode((i>>10)+55296)+String.fromCharCode(i%1024+56320)):String.fromCharCode(i)}var j0=class i extends s0{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new P0;this.combinedData=""}static fromCharData(t){let r=new i;return r.setFromCharData(t),r}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?nt(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){this.fg=t[0],this.bg=0;let r=!1;if(t[1].length>2)r=!0;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let a=t[1].charCodeAt(1);56320<=a&&a<=57343?this.content=(s-55296)*1024+a-56320+65536|t[2]<<22:r=!0}else r=!0}else this.content=t[1].charCodeAt(0)|t[2]<<22;r&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(t){if(this.getFgColorMode()!==t.getFgColorMode()||this.getFgColor()!==t.getFgColor()||this.getBgColorMode()!==t.getBgColorMode()||this.getBgColor()!==t.getBgColor()||this.isInverse()!==t.isInverse()||this.isBold()!==t.isBold()||this.isUnderline()!==t.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==t.getUnderlineStyle())return!1;let r=this.isUnderlineColorDefault(),s=t.isUnderlineColorDefault();if(!(r&&s)&&(r!==s||this.getUnderlineColor()!==t.getUnderlineColor()||this.getUnderlineColorMode()!==t.getUnderlineColorMode()))return!1}return!(this.isOverline()!==t.isOverline()||this.isBlink()!==t.isBlink()||this.isInvisible()!==t.isInvisible()||this.isItalic()!==t.isItalic()||this.isDim()!==t.isDim()||this.isStrikethrough()!==t.isStrikethrough())}};var J0=class extends W{constructor(t,r,s){super();this._renderCallback=t;this._coreBrowserService=r;this._optionsService=s;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",a=>{this.setIntervalDuration(a)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(M(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(t){this._needsBlinkInViewport!==t&&(this._needsBlinkInViewport=t,this._updateIntervalState())}setViewportVisible(t){this._isViewportVisible!==t&&(this._isViewportVisible=t,this._updateIntervalState())}setIntervalDuration(t){t!==this._intervalDuration&&(this._intervalDuration=t,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let r=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),r||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var te=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function ie(i,e,t,r){let s=g(i.createProgram());if(i.attachShader(s,g(at(i,i.VERTEX_SHADER,e,r))),i.attachShader(s,g(at(i,i.FRAGMENT_SHADER,t,r))),i.linkProgram(s),i.getProgramParameter(s,i.LINK_STATUS))return s;r.error(i.getProgramInfoLog(s)),i.deleteProgram(s)}function at(i,e,t,r){let s=g(i.createShader(e));if(i.shaderSource(s,t),i.compileShader(s),i.getShaderParameter(s,i.COMPILE_STATUS))return s;r.error(i.getShaderInfoLog(s)),i.deleteShader(s)}function ot(i,e){let t=Math.min(i.length*2,e),r=new Float32Array(t);for(let s=0;sn.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=g(n.getUniformLocation(this._program,"u_resolution")),this._textureLocation=g(n.getUniformLocation(this._program,"u_texture"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,2,n.FLOAT,!1,44,0),n.vertexAttribDivisor(2,1),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,n.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(3,1),n.enableVertexAttribArray(4),n.vertexAttribPointer(4,1,n.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(4,1),n.enableVertexAttribArray(5),n.vertexAttribPointer(5,2,n.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(5,1),n.enableVertexAttribArray(6),n.vertexAttribPointer(6,2,n.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(6,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.useProgram(this._program);let p=new Int32Array(J.maxAtlasPages);for(let c=0;cn.deleteTexture(u.texture))),n.activeTexture(n.TEXTURE0+c),n.bindTexture(n.TEXTURE_2D,u.texture),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_S,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_T,n.CLAMP_TO_EDGE),n.texImage2D(n.TEXTURE_2D,0,n.RGBA,1,1,0,n.RGBA,n.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[c]=u}n.enable(n.BLEND),n.blendFunc(n.SRC_ALPHA,n.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(t,r,s,a,o,n,L,T,h){this._updateCell(this._vertices.attributes,t,r,s,a,o,n,L,T,h)}_updateCell(t,r,s,a,o,n,L,T,h,d){if(N=(s*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,o,n,L,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,o,n,L,!1,this._terminal.element),Ie=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),o!==d&&S.offset.x>Ie?(k0=S.offset.x-Ie,t[N]=-(S.offset.x-k0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-k0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+k0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-k0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Ze(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let s=0;for(;s=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=L,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function Tt(){return new Fe}var lt=2147483648,se=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=Tt()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var jt=`#version 300 es -+}`}var N=0,S,Ie=0,k0=0,re=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._optionsService=a;this._logService=o;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let n=this._gl;J.maxAtlasPages===void 0&&(J.maxAtlasPages=Math.min(32,g(n.getParameter(n.MAX_TEXTURE_IMAGE_UNITS))),J.maxTextureSize=g(n.getParameter(n.MAX_TEXTURE_SIZE))),this._program=g(ie(n,Xt,Yt(J.maxAtlasPages),this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=g(n.getUniformLocation(this._program,"u_resolution")),this._textureLocation=g(n.getUniformLocation(this._program,"u_texture"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,2,n.FLOAT,!1,44,0),n.vertexAttribDivisor(2,1),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,n.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(3,1),n.enableVertexAttribArray(4),n.vertexAttribPointer(4,1,n.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(4,1),n.enableVertexAttribArray(5),n.vertexAttribPointer(5,2,n.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(5,1),n.enableVertexAttribArray(6),n.vertexAttribPointer(6,2,n.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(6,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.useProgram(this._program);let p=new Int32Array(J.maxAtlasPages);for(let c=0;cn.deleteTexture(u.texture))),n.activeTexture(n.TEXTURE0+c),n.bindTexture(n.TEXTURE_2D,u.texture),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_S,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_T,n.CLAMP_TO_EDGE),n.texImage2D(n.TEXTURE_2D,0,n.RGBA,1,1,0,n.RGBA,n.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[c]=u}n.enable(n.BLEND),n.blendFunc(n.SRC_ALPHA,n.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){if(!this._atlas)return!0;let _g=this._atlas._clearModelGeneration||0;return this._lastSeenClearModelGeneration!==_g?(this._lastSeenClearModelGeneration=_g,!0):!1}updateCell(t,r,s,a,o,n,L,T,h){this._updateCell(this._vertices.attributes,t,r,s,a,o,n,L,T,h)}_updateCell(t,r,s,a,o,n,L,T,h,d){if(N=(s*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,o,n,L,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,o,n,L,!1,this._terminal.element),Ie=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),o!==d&&S.offset.x>Ie?(k0=S.offset.x-Ie,t[N]=-(S.offset.x-k0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-k0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+k0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-k0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Ze(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let s=0;for(;s=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=L,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function Tt(){return new Fe}var lt=2147483648,se=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=Tt()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var jt=`#version 300 es ++}`}var N=0,S,Ie=0,k0=0,re=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._optionsService=a;this._logService=o;this._activeBuffer=0;this._vertices={count:0,attributes:new Float32Array(0),attributesBuffers:[new Float32Array(0),new Float32Array(0)]};let n=this._gl;J.maxAtlasPages===void 0&&(J.maxAtlasPages=Math.min(32,g(n.getParameter(n.MAX_TEXTURE_IMAGE_UNITS))),J.maxTextureSize=g(n.getParameter(n.MAX_TEXTURE_SIZE))),this._program=g(ie(n,Xt,Yt(J.maxAtlasPages),this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection")),this._resolutionLocation=g(n.getUniformLocation(this._program,"u_resolution")),this._textureLocation=g(n.getUniformLocation(this._program,"u_texture"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,2,n.FLOAT,!1,44,0),n.vertexAttribDivisor(2,1),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,n.FLOAT,!1,44,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(3,1),n.enableVertexAttribArray(4),n.vertexAttribPointer(4,1,n.FLOAT,!1,44,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(4,1),n.enableVertexAttribArray(5),n.vertexAttribPointer(5,2,n.FLOAT,!1,44,5*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(5,1),n.enableVertexAttribArray(6),n.vertexAttribPointer(6,2,n.FLOAT,!1,44,7*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(6,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,44,9*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.useProgram(this._program);let p=new Int32Array(J.maxAtlasPages);for(let c=0;cn.deleteTexture(u.texture))),n.activeTexture(n.TEXTURE0+c),n.bindTexture(n.TEXTURE_2D,u.texture),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_S,n.CLAMP_TO_EDGE),n.texParameteri(n.TEXTURE_2D,n.TEXTURE_WRAP_T,n.CLAMP_TO_EDGE),n.texImage2D(n.TEXTURE_2D,0,n.RGBA,1,1,0,n.RGBA,n.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[c]=u}n.enable(n.BLEND),n.blendFunc(n.SRC_ALPHA,n.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){if(!this._atlas)return!0;let _g=this._atlas._clearModelGeneration||0;return this._lastSeenClearModelGeneration!==_g?(this._lastSeenClearModelGeneration=_g,!0):!1}updateCell(t,r,s,a,o,n,L,T,h){this._updateCell(this._vertices.attributes,t,r,s,a,o,n,L,T,h)}_updateCell(t,r,s,a,o,n,L,T,h,d){if(N=(s*this._terminal.cols+r)*11,a===0||a===void 0){t.fill(0,N,N+11-1-2);return}this._atlas&&(T&&T.length>1?S=this._atlas.getRasterizedGlyphCombinedChar(T,o,n,L,!1,this._terminal.element):S=this._atlas.getRasterizedGlyph(a,o,n,L,!1,this._terminal.element),Ie=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),o!==d&&S.offset.x>Ie?(k0=S.offset.x-Ie,t[N]=-(S.offset.x-k0)+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=(S.size.x-k0)/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x+k0/this._atlas.pages[S.texturePage].canvas.width,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x-k0/this._atlas.pages[S.texturePage].canvas.width,t[N+8]=S.sizeClipSpace.y):(t[N]=-S.offset.x+this._dimensions.device.char.left,t[N+1]=-S.offset.y+this._dimensions.device.char.top,t[N+2]=S.size.x/this._dimensions.device.canvas.width,t[N+3]=S.size.y/this._dimensions.device.canvas.height,t[N+4]=S.texturePage,t[N+5]=S.texturePositionClipSpace.x,t[N+6]=S.texturePositionClipSpace.y,t[N+7]=S.sizeClipSpace.x,t[N+8]=S.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Ze(a,h,S.size.x,this._dimensions.device.cell.width)&&(t[N+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let t=this._terminal,r=t.cols*t.rows*11;this._vertices.count!==r?this._vertices.attributes=new Float32Array(r):this._vertices.attributes.fill(0);let s=0;for(;s=e.rows||T<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=L,this.viewportCappedEndRow=T,this.startCol=t[0],this.endCol=r[0]}isCellSelected(e,t,r){return this.hasSelection?(r-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&r>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&r<=this.viewportCappedEndRow:r>this.viewportStartRow&&r=this.startCol&&t=this.startCol):!1}};function Tt(){return new Fe}var lt=2147483648,se=class{constructor(){this.cells=new Uint32Array(0),this.lineLengths=new Uint32Array(0),this.selection=Tt()}resize(e,t){let r=e*t*4;r!==this.cells.length&&(this.cells=new Uint32Array(r),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}};var jt=`#version 300 es layout (location = 0) in vec2 a_position; layout (location = 1) in vec2 a_size; layout (location = 2) in vec4 a_color; +@@ -71,5 +71,5 @@ out vec4 outColor; + + void main() { + outColor = v_color; +-}`,l0=8,Me=l0*Float32Array.BYTES_PER_ELEMENT,e1=20*l0,ne=class{constructor(){this.attributes=new Float32Array(e1),this.count=0}},p0=0,Lt=0,ht=0,dt=0,_t=0,ct=0,ut=0,ae=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._themeService=a;this._logService=o;this._vertices=new ne;this._verticesCursor=new ne;let n=this._gl;this._program=g(ie(n,jt,Jt,this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,n.FLOAT,!1,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,!1,Me,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(2,1),this._updateCachedColors(a.colors),this._register(this._themeService.onChangeColors(p=>{this._updateCachedColors(p),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){let r=this._gl;r.useProgram(this._program),r.bindVertexArray(this._vertexArrayObject),r.uniformMatrix4fv(this._projectionLocation,!1,te),r.bindBuffer(r.ARRAY_BUFFER,this._attributesBuffer),r.bufferData(r.ARRAY_BUFFER,t.attributes,r.DYNAMIC_DRAW),r.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,r.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){let r=this._terminal,s=this._vertices,a=1,o,n,L,T,h,d,_,p,c,u,C;for(o=0;o>24&255)/255,_t=(p0>>16&255)/255,ct=(p0>>8&255)/255,ut=1,this._addRectangle(t.attributes,r,Lt,ht,(n-o)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,dt,_t,ct,ut)}_addRectangle(t,r,s,a,o,n,L,T,h,d){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L,t[r+5]=T,t[r+6]=h,t[r+7]=d}_addRectangleFloat(t,r,s,a,o,n,L){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L[0],t[r+5]=L[1],t[r+6]=L[2],t[r+7]=L[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(t.rgba&255)/255])}};var oe=class extends W{constructor(t,r,s,a,o,n,L,T){super();this._container=r;this._alpha=o;this._coreBrowserService=n;this._optionsService=L;this._themeService=T;this._deviceCharWidth=0;this._deviceCharHeight=0;this._deviceCellWidth=0;this._deviceCellHeight=0;this._deviceCharLeft=0;this._deviceCharTop=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add(`xterm-${s}-layer`),this._canvas.style.zIndex=a.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(h=>{this._refreshCharAtlas(t,h),this.reset(t)})),this._register(M(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=g(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,r,s){}handleSelectionChanged(t,r,s,a=!1){}_setTransparency(t,r){if(r===this._alpha)return;let s=this._canvas;this._alpha=r,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,s),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,r){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=Y0(t,this._optionsService.rawOptions,r,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,r){this._deviceCellWidth=r.device.cell.width,this._deviceCellHeight=r.device.cell.height,this._deviceCharWidth=r.device.char.width,this._deviceCharHeight=r.device.char.height,this._deviceCharLeft=r.device.char.left,this._deviceCharTop=r.device.char.top,this._canvas.width=r.device.canvas.width,this._canvas.height=r.device.canvas.height,this._canvas.style.width=`${r.css.canvas.width}px`,this._canvas.style.height=`${r.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,r,s=1){this._ctx.fillRect(t*this._deviceCellWidth,(r+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,s*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,r,s,a){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight))}_fillCharTrueColor(t,r,s,a){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=K0,this._clipCell(s,a,r.getWidth()),this._ctx.fillText(r.getChars(),s*this._deviceCellWidth+this._deviceCharLeft,a*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,r,s){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,r,s){let a=r?t.options.fontWeightBold:t.options.fontWeight;return`${s?"italic":""} ${a} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}};var Te=class extends oe{constructor(e,t,r,s,a,o,n){super(r,e,"link",t,!0,a,o,n),this._register(s.onShowLinkUnderline(L=>this._handleShowLinkUnderline(L))),this._register(s.onHideLinkUnderline(L=>this._handleHideLinkUnderline(L)))}resize(e,t){super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&st(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;tthis._handleColorChange())),this._cellColorResolver=new G0(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new Te(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=Ue(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new J0(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(le(this._canvas,"webglcontextlost",c=>{this._logService.debug("webglcontextlost event received"),c.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(c)},3e3)})),this._register(le(this._canvas,"webglcontextrestored",c=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,be(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=ve(this._canvas,this._coreBrowserService.window,(c,u)=>this._setCanvasDevicePixelDimensions(c,u)),this._register(this._coreBrowserService.onWindowChange(c=>{this._observerDisposable.value=ve(this._canvas,c,(u,C)=>this._setCanvasDevicePixelDimensions(u,C))})),this._register(le(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(M(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(let c of this._renderLayers)c.dispose();this._canvas.parentElement?.removeChild(this._canvas),be(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,r){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(let s of this._renderLayers)s.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,r,s){for(let a of this._renderLayers)a.handleSelectionChanged(this._terminal,t,r,s);this._model.selection.update(this._core,t,r,s),this._requestRedrawViewport()}handleCursorMove(){for(let t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new ae(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new re(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=!1;return}let t=Y0(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=De(c0.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),c0.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,r){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0;else return;for(let o of this._renderLayers)o.handleGridChanged(this._terminal,t,r);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=!1,a=0;for(;this._charAtlas&&this._glyphRenderer.value.beginFrame()&&a++<32;)s=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);s&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new q0(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,r){let s=this._core,a=this._workCell,o,n,L,T,h,d,_,p,c,u,C,Q,m,R,F,E,y;t=pt(t,s.rows-1,0),r=pt(r,s.rows-1,0);let Y=this._coreService.decPrivateModes.cursorStyle??s.options.cursorStyle??"block",X=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,A0=X-s.buffer.ydisp,t0=Math.min(this._terminal.buffer.active.cursorX,s.cols-1),L0=-1,A=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let n0=!1;for(n=t;n<=r;n++){L=n+s.buffer.ydisp;let a0=s.buffer.lines.get(L);if(!a0){for(this._model.lineLengths[n]=0,E=0;E=_,c=E,h.length>0&&E===h[0][0]&&p){u=h.shift();let q=this._model.selection.isCellSelected(this._terminal,u[0],L);for(F=u[0]+1;F=u[1],p?(d=!0,a=new ge(a,T.translateToString(!0,u[0],u[1]),u[1]-u[0]),c=u[1]-1):_=u[1]}if(Q=a.getChars(),m=a.getCode(),F=(n*s.cols+E)*4,!$&&a.isBlink()&&($=!0),this._cellColorResolver.resolve(a,E,L,this.dimensions.device.cell.width,this.dimensions.device.cell.height),A&&L===X&&(E===t0&&(this._model.cursor={x:t0,y:A0,width:a.getWidth(),style:this._coreBrowserService.isFocused?Y:s.options.cursorInactiveStyle,cursorWidth:s.options.cursorWidth,dpr:this._devicePixelRatio},L0=t0+a.getWidth()-1),E>=t0&&E<=L0&&(this._coreBrowserService.isFocused&&Y==="block"||this._coreBrowserService.isFocused===!1&&s.options.cursorInactiveStyle==="block")&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&a.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),m!==0&&(this._model.lineLengths[n]=E+1),!(this._model.cells[F]===m&&this._model.cells[F+1]===this._cellColorResolver.result.bg&&this._model.cells[F+2]===this._cellColorResolver.result.fg&&this._model.cells[F+3]===this._cellColorResolver.result.ext)&&(n0=!0,Q.length>1&&(m|=lt),this._model.cells[F]=m,this._model.cells[F+1]=this._cellColorResolver.result.bg,this._model.cells[F+2]=this._cellColorResolver.result.fg,this._model.cells[F+3]=this._cellColorResolver.result.ext,R=a.getWidth(),this._glyphRenderer.value.updateCell(E,n,m,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,Q,R,o),d)){for(a=this._workCell,E++;E<=c;E++)y=(n*s.cols+E)*4,this._nullModelCell(E,n,y,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);E--}}this._setRowBlinkState(n,$)}n0&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,r,s,a,o,n){this._glyphRenderer.value.updateCell(t,r,0,a,o,n,"",0,0),this._model.cells[s]=0,this._model.cells[s+1]=a,this._model.cells[s+2]=o,this._model.cells[s+3]=n}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,r){this._rowHasBlinkingCells[t]!==r&&(this._rowHasBlinkingCells[t]=r,this._rowHasBlinkingCellsCount+=r?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,r){this._canvas.width===t&&this._canvas.height===r||(this._canvas.width=t,this._canvas.height=r,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}},ge=class extends s0{constructor(t,r,s){super();this.content=0;this.combinedData="";this.fg=t.fg,this.bg=t.bg,this.combinedData=r,this._width=s}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function pt(i,e,t=0){return Math.max(Math.min(i,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",r))throw new Error("WebGL2 is only supported on Safari 16 and above")}super();this._onChangeTextureAtlas=this._register(new U);this.onChangeTextureAtlas=this._onChangeTextureAtlas.event;this._onAddTextureAtlasCanvas=this._register(new U);this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=this._register(new U);this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._onContextLoss=this._register(new U);this.onContextLoss=this._onContextLoss.event;this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){let r=t._core;if(!t.element){this._register(r.onWillOpen(()=>this.activate(t)));return}this._terminal=t;let s=r.coreService,a=r.optionsService,o=r,n=o._renderService,L=o._characterJoinerService,T=o._charSizeService,h=o._coreBrowserService,d=o._decorationService,_=o._logService,p=o._themeService;this._renderer=this._register(new Le(t,L,T,h,s,d,_,a,p,this._customGlyphs,this._preserveDrawingBuffer)),this._register(c0.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(c0.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(c0.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(c0.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),n.setRenderer(this._renderer),this._register(M(()=>{if(this._terminal._core._store._isDisposed)return;let c=this._terminal._core._renderService;c.setRenderer(this._terminal._core._createRenderer()),c.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}};export{Ct as WebglAddon}; ++}`,l0=8,Me=l0*Float32Array.BYTES_PER_ELEMENT,e1=20*l0,ne=class{constructor(){this.attributes=new Float32Array(e1),this.count=0}},p0=0,Lt=0,ht=0,dt=0,_t=0,ct=0,ut=0,ae=class extends W{constructor(t,r,s,a,o){super();this._terminal=t;this._gl=r;this._dimensions=s;this._themeService=a;this._logService=o;this._vertices=new ne;this._verticesCursor=new ne;let n=this._gl;this._program=g(ie(n,jt,Jt,this._logService)),this._register(M(()=>n.deleteProgram(this._program))),this._projectionLocation=g(n.getUniformLocation(this._program,"u_projection"));let L=this._vertexArrayObject=n.createVertexArray();this._register(M(()=>n.deleteVertexArray(L))),n.bindVertexArray(L);let T=new Float32Array([0,0,1,0,0,1,1,1]),h=n.createBuffer();this._register(M(()=>n.deleteBuffer(h))),n.bindBuffer(n.ARRAY_BUFFER,h),n.bufferData(n.ARRAY_BUFFER,T,n.STATIC_DRAW),n.enableVertexAttribArray(3),n.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);let d=new Uint8Array([0,1,2,3]),_=n.createBuffer();this._register(M(()=>n.deleteBuffer(_))),n.bindBuffer(n.ELEMENT_ARRAY_BUFFER,_),n.bufferData(n.ELEMENT_ARRAY_BUFFER,d,n.STATIC_DRAW),this._attributesBuffer=g(n.createBuffer()),this._register(M(()=>n.deleteBuffer(this._attributesBuffer))),n.bindBuffer(n.ARRAY_BUFFER,this._attributesBuffer),n.enableVertexAttribArray(0),n.vertexAttribPointer(0,2,n.FLOAT,!1,Me,0),n.vertexAttribDivisor(0,1),n.enableVertexAttribArray(1),n.vertexAttribPointer(1,2,n.FLOAT,!1,Me,2*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(1,1),n.enableVertexAttribArray(2),n.vertexAttribPointer(2,4,n.FLOAT,!1,Me,4*Float32Array.BYTES_PER_ELEMENT),n.vertexAttribDivisor(2,1),this._updateCachedColors(a.colors),this._register(this._themeService.onChangeColors(p=>{this._updateCachedColors(p),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(t){let r=this._gl;r.useProgram(this._program),r.bindVertexArray(this._vertexArrayObject),r.uniformMatrix4fv(this._projectionLocation,!1,te),r.bindBuffer(r.ARRAY_BUFFER,this._attributesBuffer),r.bufferData(r.ARRAY_BUFFER,t.attributes,r.DYNAMIC_DRAW),r.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,r.UNSIGNED_BYTE,0,t.count)}handleResize(){this._updateViewportRectangle()}setDimensions(t){this._dimensions=t}_updateCachedColors(t){this._bgFloat=this._colorToFloat32Array(t.background),this._cursorFloat=this._colorToFloat32Array(t.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(t){let r=this._terminal,s=this._vertices,a=1,o,n,L,T,h,d,_,p,c,u,C;for(o=0;o>24&255)/255,_t=(p0>>16&255)/255,ct=(p0>>8&255)/255,ut=1,this._addRectangle(t.attributes,r,Lt,ht,(n-o)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,dt,_t,ct,ut)}_addRectangle(t,r,s,a,o,n,L,T,h,d){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L,t[r+5]=T,t[r+6]=h,t[r+7]=d}_addRectangleFloat(t,r,s,a,o,n,L){t[r]=s/this._dimensions.device.canvas.width,t[r+1]=a/this._dimensions.device.canvas.height,t[r+2]=o/this._dimensions.device.canvas.width,t[r+3]=n/this._dimensions.device.canvas.height,t[r+4]=L[0],t[r+5]=L[1],t[r+6]=L[2],t[r+7]=L[3]}_colorToFloat32Array(t){return new Float32Array([(t.rgba>>24&255)/255,(t.rgba>>16&255)/255,(t.rgba>>8&255)/255,(t.rgba&255)/255])}};var oe=class extends W{constructor(t,r,s,a,o,n,L,T){super();this._container=r;this._alpha=o;this._coreBrowserService=n;this._optionsService=L;this._themeService=T;this._deviceCharWidth=0;this._deviceCharHeight=0;this._deviceCellWidth=0;this._deviceCellHeight=0;this._deviceCharLeft=0;this._deviceCharTop=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add(`xterm-${s}-layer`),this._canvas.style.zIndex=a.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(h=>{this._refreshCharAtlas(t,h),this.reset(t)})),this._register(M(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=g(this._canvas.getContext("2d",{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(t){}handleFocus(t){}handleCursorMove(t){}handleGridChanged(t,r,s){}handleSelectionChanged(t,r,s,a=!1){}_setTransparency(t,r){if(r===this._alpha)return;let s=this._canvas;this._alpha=r,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,s),this._refreshCharAtlas(t,this._themeService.colors),this.handleGridChanged(t,0,t.rows-1)}_refreshCharAtlas(t,r){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=Y0(t,this._optionsService.rawOptions,r,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(t,r){this._deviceCellWidth=r.device.cell.width,this._deviceCellHeight=r.device.cell.height,this._deviceCharWidth=r.device.char.width,this._deviceCharHeight=r.device.char.height,this._deviceCharLeft=r.device.char.left,this._deviceCharTop=r.device.char.top,this._canvas.width=r.device.canvas.width,this._canvas.height=r.device.canvas.height,this._canvas.style.width=`${r.css.canvas.width}px`,this._canvas.style.height=`${r.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(t,this._themeService.colors)}_fillBottomLineAtCells(t,r,s=1){this._ctx.fillRect(t*this._deviceCellWidth,(r+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,s*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(t,r,s,a){this._alpha?this._ctx.clearRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,a*this._deviceCellHeight))}_fillCharTrueColor(t,r,s,a){this._ctx.font=this._getFont(t,!1,!1),this._ctx.textBaseline=K0,this._clipCell(s,a,r.getWidth()),this._ctx.fillText(r.getChars(),s*this._deviceCellWidth+this._deviceCharLeft,a*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(t,r,s){this._ctx.beginPath(),this._ctx.rect(t*this._deviceCellWidth,r*this._deviceCellHeight,s*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(t,r,s){let a=r?t.options.fontWeightBold:t.options.fontWeight;return`${s?"italic":""} ${a} ${t.options.fontSize*this._coreBrowserService.dpr}px ${t.options.fontFamily}`}};var Te=class extends oe{constructor(e,t,r,s,a,o,n){super(r,e,"link",t,!0,a,o,n),this._register(s.onShowLinkUnderline(L=>this._handleShowLinkUnderline(L))),this._register(s.onHideLinkUnderline(L=>this._handleHideLinkUnderline(L)))}resize(e,t){super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&st(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;tthis._handleColorChange())),this._cellColorResolver=new G0(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new Te(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,T,this._themeService)],this.dimensions=Ue(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(T.onOptionChange(()=>this._handleOptionsChanged())),this._textBlinkStateManager=this._register(new J0(()=>this._requestRedrawViewport(),this._coreBrowserService,this._optionsService)),this._resetBlinkingRowState(),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(le(this._canvas,"webglcontextlost",c=>{this._logService.debug("webglcontextlost event received"),c.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,this._logService.warn("webgl context not restored; firing onContextLoss"),this._onContextLoss.fire(c)},3e3)})),this._register(le(this._canvas,"webglcontextrestored",c=>{this._logService.warn("webglcontextrestored event received"),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,be(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=ve(this._canvas,this._coreBrowserService.window,(c,u)=>this._setCanvasDevicePixelDimensions(c,u)),this._register(this._coreBrowserService.onWindowChange(c=>{this._observerDisposable.value=ve(this._canvas,c,(u,C)=>this._setCanvasDevicePixelDimensions(u,C))})),this._register(le(this._coreBrowserService.mainDocument,"mousedown",()=>this._cursorBlinkStateManager.value?.restartBlinkAnimation())),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(M(()=>{clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0;for(let c of this._renderLayers)c.dispose();this._canvas.parentElement?.removeChild(this._canvas),be(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(t,r){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows),this._resetBlinkingRowState();for(let s of this._renderLayers)s.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1),this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0})}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let t of this._renderLayers)t.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let t of this._renderLayers)t.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleViewportVisibilityChange(t){this._textBlinkStateManager.setViewportVisible(t)}handleSelectionChanged(t,r,s){for(let a of this._renderLayers)a.handleSelectionChanged(this._terminal,t,r,s);this._model.selection.update(this._core,t,r,s),this._requestRedrawViewport()}handleCursorMove(){for(let t of this._renderLayers)t.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new ae(this._terminal,this._gl,this.dimensions,this._themeService,this._logService),this._glyphRenderer.value=new re(this._terminal,this._gl,this.dimensions,this._optionsService,this._logService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=!1;return}let t=Y0(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize,this._customGlyphs);this._charAtlas!==t&&(this._onChangeTextureAtlas.fire(t.pages[0].canvas),this._charAtlasDisposable.value=De(c0.forward(t.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),c0.forward(t.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=t,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(t){this._model.clear(),t&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(let t of this._renderLayers)t.reset(this._terminal);this._resetBlinkingRowState(),this._textBlinkStateManager.setNeedsBlinkInViewport(!1),this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(t,r){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0;else return;for(let o of this._renderLayers)o.handleGridChanged(this._terminal,t,r);if(!this._glyphRenderer.value||!this._rectangleRenderer.value)return;this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(t,r);let s=!1,a=0;for(;this._charAtlas&&a++<32&&this._glyphRenderer.value.beginFrame();)s=!0,this._clearModel(!0),this._updateModel(0,this._terminal.rows-1);s&&this._glyphRenderer.value.invalidateAtlasTextures(),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor()}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new q0(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(t,r){let s=this._core,a=this._workCell,o,n,L,T,h,d,_,p,c,u,C,Q,m,R,F,E,y;t=pt(t,s.rows-1,0),r=pt(r,s.rows-1,0);let Y=this._coreService.decPrivateModes.cursorStyle??s.options.cursorStyle??"block",X=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,A0=X-s.buffer.ydisp,t0=Math.min(this._terminal.buffer.active.cursorX,s.cols-1),L0=-1,A=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let n0=!1;for(n=t;n<=r;n++){L=n+s.buffer.ydisp;let a0=s.buffer.lines.get(L);if(!a0){for(this._model.lineLengths[n]=0,E=0;E=_,c=E,h.length>0&&E===h[0][0]&&p){u=h.shift();let q=this._model.selection.isCellSelected(this._terminal,u[0],L);for(F=u[0]+1;F=u[1],p?(d=!0,a=new ge(a,T.translateToString(!0,u[0],u[1]),u[1]-u[0]),c=u[1]-1):_=u[1]}if(Q=a.getChars(),m=a.getCode(),F=(n*s.cols+E)*4,!$&&a.isBlink()&&($=!0),this._cellColorResolver.resolve(a,E,L,this.dimensions.device.cell.width,this.dimensions.device.cell.height),A&&L===X&&(E===t0&&(this._model.cursor={x:t0,y:A0,width:a.getWidth(),style:this._coreBrowserService.isFocused?Y:s.options.cursorInactiveStyle,cursorWidth:s.options.cursorWidth,dpr:this._devicePixelRatio},L0=t0+a.getWidth()-1),E>=t0&&E<=L0&&(this._coreBrowserService.isFocused&&Y==="block"||this._coreBrowserService.isFocused===!1&&s.options.cursorInactiveStyle==="block")&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),this._textBlinkStateManager.isEnabled&&!this._textBlinkStateManager.isBlinkOn&&a.isBlink()&&(this._cellColorResolver.result.fg|=1073741824),m!==0&&(this._model.lineLengths[n]=E+1),!(this._model.cells[F]===m&&this._model.cells[F+1]===this._cellColorResolver.result.bg&&this._model.cells[F+2]===this._cellColorResolver.result.fg&&this._model.cells[F+3]===this._cellColorResolver.result.ext)&&(n0=!0,Q.length>1&&(m|=lt),this._model.cells[F]=m,this._model.cells[F+1]=this._cellColorResolver.result.bg,this._model.cells[F+2]=this._cellColorResolver.result.fg,this._model.cells[F+3]=this._cellColorResolver.result.ext,R=a.getWidth(),this._glyphRenderer.value.updateCell(E,n,m,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,Q,R,o),d)){for(a=this._workCell,E++;E<=c;E++)y=(n*s.cols+E)*4,this._nullModelCell(E,n,y,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext);E--}}this._setRowBlinkState(n,$)}n0&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model),this._updateTextBlinkState()}_nullModelCell(t,r,s,a,o,n){this._glyphRenderer.value.updateCell(t,r,0,a,o,n,"",0,0),this._model.cells[s]=0,this._model.cells[s+1]=a,this._model.cells[s+2]=o,this._model.cells[s+3]=n}_resetBlinkingRowState(){this._rowHasBlinkingCells=new Array(this._terminal.rows).fill(!1),this._rowHasBlinkingCellsCount=0}_setRowBlinkState(t,r){this._rowHasBlinkingCells[t]!==r&&(this._rowHasBlinkingCells[t]=r,this._rowHasBlinkingCellsCount+=r?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(t,r){this._canvas.width===t&&this._canvas.height===r||(this._canvas.width=t,this._canvas.height=r,this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1,sync:!0}))}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let t=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:t,end:t})}},ge=class extends s0{constructor(t,r,s){super();this.content=0;this.combinedData="";this.fg=t.fg,this.bg=t.bg,this.combinedData=r,this._width=s}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(t){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function pt(i,e,t=0){return Math.max(Math.min(i,e),t)}var Ct=class extends W{constructor(t){if(ue&&He()<16){let r={antialias:!1,depth:!1,preserveDrawingBuffer:!0};if(!document.createElement("canvas").getContext("webgl2",r))throw new Error("WebGL2 is only supported on Safari 16 and above")}super();this._onChangeTextureAtlas=this._register(new U);this.onChangeTextureAtlas=this._onChangeTextureAtlas.event;this._onAddTextureAtlasCanvas=this._register(new U);this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event;this._onRemoveTextureAtlasCanvas=this._register(new U);this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event;this._onContextLoss=this._register(new U);this.onContextLoss=this._onContextLoss.event;this._customGlyphs=t?.customGlyphs??!0,this._preserveDrawingBuffer=t?.preserveDrawingBuffer}activate(t){let r=t._core;if(!t.element){this._register(r.onWillOpen(()=>this.activate(t)));return}this._terminal=t;let s=r.coreService,a=r.optionsService,o=r,n=o._renderService,L=o._characterJoinerService,T=o._charSizeService,h=o._coreBrowserService,d=o._decorationService,_=o._logService,p=o._themeService;this._renderer=this._register(new Le(t,L,T,h,s,d,_,a,p,this._customGlyphs,this._preserveDrawingBuffer)),this._register(c0.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(c0.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(c0.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(c0.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),n.setRenderer(this._renderer),this._register(M(()=>{if(this._terminal._core._store._isDisposed)return;let c=this._terminal._core._renderService;c.setRenderer(this._terminal._core._createRenderer()),c.handleResize(t.cols,t.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}};export{Ct as WebglAddon}; + //# sourceMappingURL=addon-webgl.mjs.map +diff --git a/src/GlyphRenderer.ts b/src/GlyphRenderer.ts +index be1990c2a61a0ff5a37622ef7b6d8772e7a1056c..97a02452c3ff9e43b9dfb37e8b1c9b20e7831804 100644 +--- a/src/GlyphRenderer.ts ++++ b/src/GlyphRenderer.ts +@@ -363,7 +363,10 @@ export class GlyphRenderer extends Disposable { + // Bind the atlas page texture if they have changed. AtlasPage.version is globally + // monotonic, so a page object swap at the same index (which happens after a page merge) + // is detected by the same comparison. +- for (let i = 0; i < this._atlas.pages.length; i++) { ++ // pages can temporarily exceed the texture-unit budget (merge fallback ++ // pushes past maxAtlasPages); clamp so render() cannot throw on the ++ // missing texture slot and kill rendering entirely. ++ for (let i = 0; i < Math.min(this._atlas.pages.length, this._atlasTextures.length); i++) { + if (this._atlas.pages[i].version !== this._atlasTextures[i].version) { + this._bindAtlasPageTexture(gl, this._atlas, i); + } +diff --git a/src/TextureAtlas.ts b/src/TextureAtlas.ts +index 1c3e08a1cb14208e3d8b6dd726c79285200b9df4..4785172e35b65fdc9b2c3b4dad24669a9b11b860 100644 +--- a/src/TextureAtlas.ts ++++ b/src/TextureAtlas.ts +@@ -140,12 +140,23 @@ export class TextureAtlas implements ITextureAtlas { + } + + public clearTexture(): void { +- if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) { ++ // Guard on real content, not pages[0].currentRow: after a page merge a ++ // merged page (never written directly, currentRow at origin) can occupy ++ // index 0, which made every subsequent clearTexture a silent no-op. ++ if (this._pages.every(page => page.glyphs.length === 0 && page.currentRow.x === 0 && page.currentRow.y === 0)) { + return; + } + for (const page of this._pages) { + page.clear(); + } ++ // Merges remove pages from the active set permanently; after a wipe every ++ // page is empty again, so restore them all for allocation. ++ this._activePages.length = 0; ++ for (const page of this._pages) { ++ if (page !== this._overflowSizePage) { ++ this._activePages.push(page); ++ } ++ } + this._cacheMap.clear(); + this._cacheMapCombined.clear(); + this._didWarmUp = false; +@@ -1111,6 +1122,10 @@ class AtlasPage { + + public clear(): void { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ++ // Wiped glyphs must not linger in the page stats: stale _glyphs/_usedPixels ++ // distort merge-candidate selection after a clearTexture. ++ this._glyphs.length = 0; ++ this._usedPixels = 0; + this.currentRow.x = 0; + this.currentRow.y = 0; + this.currentRow.height = 0; +diff --git a/src/WebglRenderer.ts b/src/WebglRenderer.ts +index a951efba5c75e82735cd39b6b22c4b5d5fad5928..7cc90292d352b134e7daf2bb7857da8f3f34cb9d 100644 +--- a/src/WebglRenderer.ts ++++ b/src/WebglRenderer.ts +@@ -386,7 +386,10 @@ export class WebglRenderer extends Disposable implements IRenderer { + // page's version, so re-run the update and force a full texture rebind. + let merged = false; + let mergeRetries = 0; +- while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) { ++ // Check the retry budget BEFORE beginFrame: beginFrame consumes the ++ // clear-model notification, so consuming it on the iteration that trips ++ // the limit would leave a stale model with no later rebuild. ++ while (this._charAtlas && mergeRetries++ < Constants.MERGE_RETRY_LIMIT && this._glyphRenderer.value.beginFrame()) { + merged = true; + this._clearModel(true); + this._updateModel(0, this._terminal.rows - 1); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12052fba2e0..cf5fc522fd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ patchedDependencies: hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': - hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145 + hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch node-pty@1.1.0: hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282 @@ -211,7 +211,7 @@ importers: version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287) '@xterm/addon-webgl': specifier: 0.20.0-beta.286 - version: 0.20.0-beta.286(patch_hash=296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145)(@xterm/xterm@6.1.0-beta.287) + version: 0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287) '@xterm/xterm': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -9300,7 +9300,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287 - '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145)(@xterm/xterm@6.1.0-beta.287)': + '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)': dependencies: '@xterm/xterm': 6.1.0-beta.287 From 473313fca683c9403711f7e86f4c654b02fbc329 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:45:33 +0000 Subject: [PATCH 14/95] release: v1.4.135-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a45b19a9457..28335274f70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.135-rc.0", + "version": "1.4.135-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b93a7eece1b52204f05be95003da37d7368c6ea5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 10 Jul 2026 15:10:15 -0700 Subject: [PATCH 15/95] fix(watcher): preserve isolation in electron-vite builds (#8162) --- .../ipc/parcel-watcher-entry-path.test.ts | 40 +++++++++++++++++++ src/main/ipc/parcel-watcher-entry-path.ts | 37 +++++++++++++++++ src/main/ipc/parcel-watcher-process.ts | 19 +-------- 3 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 src/main/ipc/parcel-watcher-entry-path.test.ts create mode 100644 src/main/ipc/parcel-watcher-entry-path.ts diff --git a/src/main/ipc/parcel-watcher-entry-path.test.ts b/src/main/ipc/parcel-watcher-entry-path.test.ts new file mode 100644 index 00000000000..f8b59becc78 --- /dev/null +++ b/src/main/ipc/parcel-watcher-entry-path.test.ts @@ -0,0 +1,40 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveWatcherProcessEntryPath } from './parcel-watcher-entry-path' + +describe('resolveWatcherProcessEntryPath', () => { + it('uses an adjacent entry when electron-vite appPath is already out/main', () => { + const builtMainPath = path.join(process.cwd(), 'out', 'main') + const adjacentEntry = path.join(builtMainPath, 'parcel-watcher-process-entry.js') + + expect( + resolveWatcherProcessEntryPath( + builtMainPath, + false, + (candidate) => candidate === adjacentEntry + ) + ).toBe(adjacentEntry) + }) + + it('uses the nested build entry when appPath is the project root', () => { + expect(resolveWatcherProcessEntryPath(process.cwd(), false, () => false)).toBe( + path.join(process.cwd(), 'out', 'main', 'parcel-watcher-process-entry.js') + ) + }) + + it('uses the unpacked nested entry for packaged apps', () => { + const appPath = path.join('C:', 'Orca', 'resources', 'app.asar') + + expect(resolveWatcherProcessEntryPath(appPath, true, () => true)).toBe( + path.join( + 'C:', + 'Orca', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'parcel-watcher-process-entry.js' + ) + ) + }) +}) diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts new file mode 100644 index 00000000000..90133146715 --- /dev/null +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -0,0 +1,37 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } + +function loadElectronApp(): ElectronAppPath | null { + try { + return require('electron').app ?? null + } catch { + return null + } +} + +export function resolveWatcherProcessEntryPath( + appPath: string, + isPackaged: boolean, + pathExists: (candidate: string) => boolean = existsSync +): string { + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must be forked from app.asar.unpacked. + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js') + // Why: electron-vite's unpackaged appPath is already out/main. Appending + // out/main again silently disables crash isolation in dev and E2E builds. + if (!isPackaged && pathExists(adjacentBuildEntry)) { + return adjacentBuildEntry + } + return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') +} + +export function getWatcherProcessEntryPath(): string { + const app = loadElectronApp() + return resolveWatcherProcessEntryPath( + app?.getAppPath() ?? process.cwd(), + app?.isPackaged === true + ) +} diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 58ad6097011..72e2ccf58ce 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -6,8 +6,8 @@ // they can refresh state that changed during the gap. import { fork, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' -import { join } from 'node:path' import type * as ParcelWatcher from '@parcel/watcher' +import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import type { HostToWatcherMessage, WatcherProcessEvent, @@ -42,23 +42,6 @@ let loggedInProcessFallback = false const records = new Map() const pendingUnsubscribes = new Map void>() -function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { - try { - return require('electron').app ?? null - } catch { - return null - } -} - -function getWatcherProcessEntryPath(): string { - const app = loadElectronApp() - const appPath = app?.getAppPath() ?? process.cwd() - // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the - // packaged entry must be forked from app.asar.unpacked. - const basePath = app?.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') -} - function shouldRunInProcess(entryPath: string): boolean { // Why: vitest suites mock '@parcel/watcher' at the module level; a forked // child would bypass those mocks (and could execute a stale build output), From b9ccc0d17fb8d6aa984c16ed4b422ae2841f124e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:39:38 -0700 Subject: [PATCH 16/95] Flip usage bars from percent-remaining to percent-used (#8167) Aligns status bar, tooltip, popover mocks, and mobile usage bars with the Claude/Codex harness convention (consumption meters) so a fresh account reads empty/green and a depleted one reads full/red, instead of the inverted "left" framing that misread as "full = exhausted". --- mobile/src/components/AccountUsage.tsx | 22 +++---- src/renderer/src/assets/main.css | 2 +- .../agents-orchestration/UsagePage.tsx | 28 +++++---- .../src/components/status-bar/StatusBar.tsx | 47 ++++++++------ .../status-bar/inline-usage-bars.test.tsx | 7 ++- .../status-bar-provider-menu-focus.test.tsx | 3 +- .../src/components/status-bar/tooltip.test.ts | 62 ++++++++++++++++--- .../src/components/status-bar/tooltip.tsx | 28 ++++++--- src/renderer/src/i18n/locales/en.json | 12 ++-- src/renderer/src/i18n/locales/es.json | 12 ++-- src/renderer/src/i18n/locales/ja.json | 12 ++-- src/renderer/src/i18n/locales/ko.json | 12 ++-- src/renderer/src/i18n/locales/zh.json | 12 ++-- 13 files changed, 162 insertions(+), 97 deletions(-) diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 9d85bf434b7..00b3e607487 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -21,9 +21,9 @@ export { hasRenderableUsage } from './account-usage-state' -// Why: matches desktop StatusBar convention — bars show percent remaining -// (so a fresh account renders full, a depleted one renders empty), not -// percent used. Color thresholds invert accordingly. +// Why: matches desktop StatusBar — bars show percent used (consumption), same +// as Claude/Codex harness meters. Fresh account is empty/green; depleted is +// full/red. export function UsageBar({ label, usedPercent, @@ -35,13 +35,15 @@ export function UsageBar({ unavailable: boolean loading?: boolean }) { - const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent)) + // Why: round then clamp so bar width, color, and label share one value (desktop parity). + const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent))) + // Why: same consumption bands as desktop barColor (green <60, amber <80, red ≥80). const barColor = - remaining == null + used == null ? colors.textMuted - : remaining <= 10 + : used >= 80 ? colors.statusRed - : remaining <= 30 + : used >= 60 ? colors.statusAmber : colors.statusGreen return ( @@ -52,7 +54,7 @@ export function UsageBar({ style={[ styles.usageFill, { - width: `${remaining ?? 0}%`, + width: `${used ?? 0}%`, backgroundColor: unavailable ? colors.textMuted : barColor } ]} @@ -61,9 +63,7 @@ export function UsageBar({ {loading ? ( ) : ( - - {unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`} - + {unavailable || used == null ? '—' : `${used}%`} )} ) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index a85d7fb6c43..56841b77245 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -2433,7 +2433,7 @@ } /* Brief one-shot pulse when the Codex limits popover swaps accounts and the - "100% left" label flashes from red to green. */ + "0% used" label flashes from red to green. */ @keyframes feature-wall-meta-pulse { 0% { color: rgb(220 38 38); diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/UsagePage.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/UsagePage.tsx index faac3d45669..a7f73f3b9ce 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/UsagePage.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/UsagePage.tsx @@ -77,10 +77,11 @@ function Popover(props: { pulseKey: number }): JSX.Element { const { expanded, targeted, swapped, pulseKey } = props - const sessionPctText = swapped ? '100% left' : '4% left' + // Why: bars show % used (consumption), matching the live status-bar meter. + const sessionPctText = swapped ? '0% used' : '96% used' const sessionResetText = swapped ? 'Resets in 5h' : 'Resets in 47m' - const sessionFillWidth = swapped ? '100%' : '4%' - const weeklyFillWidth = '62%' + const sessionFillWidth = swapped ? '0%' : '96%' + const weeklyFillWidth = '38%' return (
{translate( 'auto.components.feature.wall.agents.orchestration.UsagePage.05ce4ecdd3', - '62% left' + '38% used' )} } @@ -201,15 +202,15 @@ function Popover(props: {
@@ -295,11 +296,12 @@ function SwitchAccount(props: { // The 340px-wide light pill at the bottom of the panel that the popover tip // points down to. Codex chip is the "active" one — it animates from a near- -// empty red bar to a full green bar after the account swap. +// full red bar (% used) to an empty green bar after the account swap. function BottomBar(props: { swapped: boolean }): JSX.Element { - const codexFillWidth = props.swapped ? '100%' : '4%' + // Why: match live status-bar consumption meters (% used), same as the popover. + const codexFillWidth = props.swapped ? '0%' : '96%' const codexFillColor = props.swapped ? 'rgb(34 197 94)' : 'rgb(239 68 68)' - const codexMeta = props.swapped ? '100% 5h · 96% wk' : '4% 47m' + const codexMeta = props.swapped ? '0% used 5h · 4% used wk' : '96% used 47m' return (
- + {translate( 'auto.components.feature.wall.agents.orchestration.UsagePage.64265cb295', - '71% 5h' + '29% used 5h' )}
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 71cc71ffe40..59c9fe08e70 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -51,6 +51,7 @@ import { ProviderIcon, ProviderPanel, barColor, + clampUsedPercent, formatResetCreditExpiry, getProviderUsageStatusLabel } from './tooltip' @@ -932,15 +933,15 @@ function ClaudeSwitcherMenu({ } // --------------------------------------------------------------------------- -// Mini progress bar (shows remaining capacity, grey) +// Mini progress bar (shows consumption / % used, grey) // --------------------------------------------------------------------------- -function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element { +function MiniBar({ usedPct }: { usedPct: number }): React.JSX.Element { return (
) @@ -957,29 +958,32 @@ export function InlineUsageBars({ limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { + // Why: show % used (consumption), not remaining — matches harness meters (#7551). + // Keep the "used" word in compact labels so bare "32% 5h" is not read as remaining. + const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used') 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') + used: clampUsedPercent(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') + used: clampUsedPercent(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') + used: clampUsedPercent(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) + ].filter((window): window is { key: string; used: number; label: string } => window !== null) return (
- {window.left} - {window.label} + {window.used} + {usedSuffix} {window.label}
))} @@ -1071,14 +1075,16 @@ function InlineUsageSkeleton(): React.JSX.Element { } // --------------------------------------------------------------------------- -// Window label (shows percent remaining) +// Window label (shows percent used / consumption) // --------------------------------------------------------------------------- function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element { - const left = Math.max(0, Math.round(100 - w.usedPercent)) + const used = clampUsedPercent(w.usedPercent) + // Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit. return ( - {left}% {label} + {used} + {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label} ) } @@ -1150,12 +1156,13 @@ function ProviderSegment({ {visibleBuckets.map((bucket, i) => { - const left = Math.max(0, Math.round(100 - bucket.usedPercent)) + const used = clampUsedPercent(bucket.usedPercent) return ( {i > 0 && ·} - {bucket.name} {left}% + {bucket.name} {used} + {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} ) @@ -1195,7 +1202,7 @@ function ProviderSegment({ return ( - {p.session && !compact && } + {p.session && !compact && } {visibleWindows.map((window, index) => ( {index > 0 && ·} 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 index 0160978beb7..018d260768f 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -46,8 +46,9 @@ describe('InlineUsageBars', () => { ) - expect(markup).toContain('68% 5h') - expect(markup).toContain('84% wk') - expect(markup).toContain('58% Fable') + // Why: bars show % used with explicit "used" so compact labels are not ambiguous. + expect(markup).toContain('32% used 5h') + expect(markup).toContain('16% used wk') + expect(markup).toContain('42% used Fable') }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx index 143c562fcec..25dfa651bc5 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx +++ b/src/renderer/src/components/status-bar/status-bar-provider-menu-focus.test.tsx @@ -28,7 +28,8 @@ vi.mock('./tooltip', () => ({ ProviderPanel: function ProviderPanel(props: Record) { return { type: 'ProviderPanel', props } }, - barColor: () => 'bg-green-500' + barColor: () => 'bg-green-500', + clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n))) })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index dea9af3abd7..8f214ecc7fa 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -22,6 +22,8 @@ vi.mock('@/i18n/i18n', () => ({ })) import { + barColor, + clampUsedPercent, formatResetCreditExpiry, formatResetCountdown, getProviderUsageErrorMessage, @@ -409,7 +411,7 @@ describe('ProviderPanel reset rendering', () => { expect(markup).toContain('Resets in 6d 17h') }) - it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => { + it('renders MiniMax session as usedPercent so the value matches the bar', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) const p = provider({ @@ -425,14 +427,13 @@ describe('ProviderPanel reset rendering', () => { const markup = renderToStaticMarkup(ProviderPanel({ p })) - // Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the - // same source field so the two views stay consistent. - expect(markup).toContain('65%') - expect(markup).toContain('% left') - expect(markup).not.toContain('100% left') + // Why: bars show consumption (% used), matching harness meters (#7551). + expect(markup).toContain('35%') + expect(markup).toContain('% used') + expect(markup).not.toContain('% left') }) - it('clamps MiniMax session to 0% left when usedPercent reports 100', () => { + it('clamps MiniMax session to 100% used when usedPercent reports 100', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) const p = provider({ @@ -448,7 +449,52 @@ describe('ProviderPanel reset rendering', () => { const markup = renderToStaticMarkup(ProviderPanel({ p })) - expect(markup).toContain('0% left') + expect(markup).toContain('100%') + expect(markup).toContain('% used') + }) + + it('clamps over-100 usedPercent to 100% used in the panel', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 140, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + expect(markup).toContain('100%') + expect(markup).toContain('width:100%') + expect(markup).not.toContain('140%') + }) +}) + +describe('clampUsedPercent', () => { + it('rounds and clamps into 0–100', () => { + expect(clampUsedPercent(-3)).toBe(0) + expect(clampUsedPercent(32.4)).toBe(32) + expect(clampUsedPercent(32.6)).toBe(33) + expect(clampUsedPercent(100)).toBe(100) + expect(clampUsedPercent(140)).toBe(100) + }) +}) + +describe('barColor', () => { + // Why: thresholds are on % used (consumption). Guard against flipping back + // to remaining-based colors without noticing. + it('maps used percent to green / yellow / red bands', () => { + expect(barColor(0)).toBe('bg-green-500') + expect(barColor(59)).toBe('bg-green-500') + expect(barColor(60)).toBe('bg-yellow-500') + expect(barColor(79)).toBe('bg-yellow-500') + expect(barColor(80)).toBe('bg-red-500') + expect(barColor(100)).toBe('bg-red-500') }) }) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index be174965b4f..47ba6414267 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -191,13 +191,19 @@ export function getWindowSections( // `text-background` for primary text and `text-background/50` for secondary // to stay readable inside the inverted tooltip container. -// Why: color-coded by remaining capacity so users can quickly gauge urgency. -// Green = comfortable (>40% left), yellow = caution (20-40%), red = critical (<20%). -export function barColor(leftPct: number): string { - if (leftPct > 40) { +// Why: single clamp for bar width + label so status bar and tooltip never diverge. +export function clampUsedPercent(usedPercent: number): number { + return Math.max(0, Math.min(100, Math.round(usedPercent))) +} + +// Why: color-coded by consumption so users can quickly gauge urgency. +// Matches common harness usage meters (Claude/Codex): bars fill with % used. +// Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%). +export function barColor(usedPct: number): string { + if (usedPct < 60) { return 'bg-green-500' } - if (leftPct > 20) { + if (usedPct < 80) { return 'bg-yellow-500' } return 'bg-red-500' @@ -282,7 +288,9 @@ export function ProviderPanel({ if (!w) { return null } - const leftPct = Math.max(0, Math.round(100 - w.usedPercent)) + // Why: show % used (consumption), not remaining — matches Claude/Codex + // harness meters and avoids the "full green bar = exhausted" misread (#7551). + const usedPct = clampUsedPercent(w.usedPercent) const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null return ( @@ -290,14 +298,14 @@ export function ProviderPanel({
{label}
- {leftPct} - {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% left')} + {usedPct} + {translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {resetLabel && {resetLabel}}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8a91f79653c..c0f48f1cc97 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2999,9 +2999,9 @@ "2483c60695": "···", "c35af53b73": "Sign in", "f19a63e7cd": "Sign in to see usage", - "5c938d39ac": "% wk", - "54e8d6bb2d": "% Fable", - "d79c3362c4": "% 5h", + "5c938d39ac": "wk", + "54e8d6bb2d": "Fable", + "d79c3362c4": "5h", "a79c64f87e": "Fable", "8295903d17": "Restart live Claude terminals before continuing old conversations after switching.", "c98ea88392": "No other accounts", @@ -3169,7 +3169,7 @@ } }, "tooltip": { - "cedb7b99e3": "% left", + "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", "252c096536": "Weekly", @@ -10996,11 +10996,11 @@ "945865332e": "Signing in" }, "UsagePage": { - "64265cb295": "71% 5h", + "64265cb295": "29% used 5h", "be5a165875": "Switch to", "277a9c65a9": "Codex Account", "4dce5ca3aa": "Resets in 4d 3h", - "05ce4ecdd3": "62% left", + "05ce4ecdd3": "38% used", "0470aaed99": "Weekly", "f421abf962": "Session", "5e45fb1238": "Updated 1m ago", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 694ea52f824..37f55486167 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2999,9 +2999,9 @@ "2483c60695": "···", "c35af53b73": "Iniciar sesión", "f19a63e7cd": "Inicia sesión para ver el uso", - "5c938d39ac": "% semana", - "54e8d6bb2d": "% Fable", - "d79c3362c4": "% 5h", + "5c938d39ac": "semana", + "54e8d6bb2d": "Fable", + "d79c3362c4": "5h", "a79c64f87e": "Fable", "8295903d17": "Reinicia los terminales Claude activos antes de continuar conversaciones antiguas después de cambiar.", "c98ea88392": "Ninguna otra cuenta", @@ -3169,7 +3169,7 @@ } }, "tooltip": { - "cedb7b99e3": "% restante", + "cedb7b99e3": "% usado", "6d6df77f41": "No hay datos disponibles", "7f7f208060": "Mensual", "252c096536": "Semanal", @@ -10996,11 +10996,11 @@ "945865332e": "Iniciando sesión" }, "UsagePage": { - "64265cb295": "71% 5h", + "64265cb295": "29% usado 5h", "be5a165875": "Cambiar a", "277a9c65a9": "Cuenta de Codex", "4dce5ca3aa": "Se restablece en 4d 3h", - "05ce4ecdd3": "62% restante", + "05ce4ecdd3": "38% usado", "0470aaed99": "Semanal", "f421abf962": "Sesión", "5e45fb1238": "Actualizado hace 1 min", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 59b3e7640c2..eb83299b3c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2999,9 +2999,9 @@ "2483c60695": "・・・", "c35af53b73": "サインイン", "f19a63e7cd": "サインインして使用状況を確認する", - "5c938d39ac": "%週", - "54e8d6bb2d": "% Fable", - "d79c3362c4": "% 5時間", + "5c938d39ac": "週", + "54e8d6bb2d": "Fable", + "d79c3362c4": "5時間", "a79c64f87e": "Fable", "8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude terminals を再起動します。", "c98ea88392": "他のアカウントはありません", @@ -3169,7 +3169,7 @@ } }, "tooltip": { - "cedb7b99e3": "% 左", + "cedb7b99e3": "% 使用", "6d6df77f41": "利用可能なデータがありません", "7f7f208060": "毎月", "252c096536": "毎週", @@ -10996,11 +10996,11 @@ "945865332e": "サインインする" }, "UsagePage": { - "64265cb295": "71% 5時間", + "64265cb295": "29% 使用 5時間", "be5a165875": "に切り替えます", "277a9c65a9": "Codex アカウント", "4dce5ca3aa": "4d 3h でリセット", - "05ce4ecdd3": "62%残っています", + "05ce4ecdd3": "38%使用", "0470aaed99": "毎週", "f421abf962": "セッション", "5e45fb1238": "1分前に更新されました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3a81820ec53..29d25e04b65 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2999,9 +2999,9 @@ "2483c60695": "···", "c35af53b73": "로그인", "f19a63e7cd": "사용량을 보려면 로그인하세요.", - "5c938d39ac": "%주간", - "54e8d6bb2d": "% Fable", - "d79c3362c4": "% 5h", + "5c938d39ac": "주간", + "54e8d6bb2d": "Fable", + "d79c3362c4": "5h", "a79c64f87e": "Fable", "8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.", "c98ea88392": "다른 계정 없음", @@ -3169,7 +3169,7 @@ } }, "tooltip": { - "cedb7b99e3": "% 남음", + "cedb7b99e3": "% 사용", "6d6df77f41": "데이터가 없습니다", "7f7f208060": "월간", "252c096536": "주간", @@ -10996,11 +10996,11 @@ "945865332e": "로그인 중" }, "UsagePage": { - "64265cb295": "71% 5시간", + "64265cb295": "29% 사용 5시간", "be5a165875": "다음으로 전환", "277a9c65a9": "Codex 계정", "4dce5ca3aa": "4일 3시간 후에 재설정됩니다.", - "05ce4ecdd3": "62% 남음", + "05ce4ecdd3": "38% 사용", "0470aaed99": "주간", "f421abf962": "세션", "5e45fb1238": "1분 전에 업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ec6987a5999..e8c2c8a10ef 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2999,9 +2999,9 @@ "2483c60695": "···", "c35af53b73": "登录", "f19a63e7cd": "登录查看使用情况", - "5c938d39ac": "% 周", - "54e8d6bb2d": "% Fable", - "d79c3362c4": "% 5小时", + "5c938d39ac": "周", + "54e8d6bb2d": "Fable", + "d79c3362c4": "5小时", "a79c64f87e": "Fable", "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", @@ -3169,7 +3169,7 @@ } }, "tooltip": { - "cedb7b99e3": "剩余 %", + "cedb7b99e3": "% 已用", "6d6df77f41": "无可用数据", "7f7f208060": "每月", "252c096536": "每周", @@ -10996,11 +10996,11 @@ "945865332e": "登录" }, "UsagePage": { - "64265cb295": "71% 5小时", + "64265cb295": "29% 已用 5小时", "be5a165875": "切换到", "277a9c65a9": "Codex 账户", "4dce5ca3aa": "4d 3h后重置", - "05ce4ecdd3": "还剩 62%", + "05ce4ecdd3": "已用 38%", "0470aaed99": "每周", "f421abf962": "会话", "5e45fb1238": "更新于 1 分钟前", From cb92b69746f31fac9f4e51a81181e236e3285d18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:47:52 -0700 Subject: [PATCH 17/95] =?UTF-8?q?fix(i18n):=20unbreak=20main=20lint=20?= =?UTF-8?q?=E2=80=94=20locale=20parity=20for=20uploadingAttachments=20(#80?= =?UTF-8?q?56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Orca --- .../components/native-chat/native-chat-attachment-upload.ts | 4 ++-- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 2bac1a42392..2cbcf236fa8 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -84,8 +84,8 @@ export async function uploadNativeChatAttachmentPaths( const pending = toast.loading( translate( 'components.native-chat.composer.uploadingAttachments', - 'Uploading {{value0}} file{{value1}} to remote…', - { value0: paths.length, value1: paths.length === 1 ? '' : 's' } + 'Uploading {{value0}} file(s) to remote…', + { value0: paths.length } ) ) try { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c0f48f1cc97..722aba516b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12884,7 +12884,7 @@ "pastedImageLabel": "Pasted image", "imagePasteFailed": "Image paste failed.", "worktreeNotReady": "Worktree not ready — try again in a moment.", - "uploadingAttachments": "Uploading {{value0}} file{{value1}} to remote…" + "uploadingAttachments": "Uploading {{value0}} file(s) to remote…" }, "tool": { "running": "Running…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 37f55486167..30b57c386c8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12884,7 +12884,7 @@ "pastedImageLabel": "Imagen pegada", "imagePasteFailed": "Error al pegar imagen.", "worktreeNotReady": "Worktree no está listo — intenta de nuevo en un momento.", - "uploadingAttachments": "Subiendo {{value0}} archivo{{value1}} al remoto…" + "uploadingAttachments": "Subiendo {{value0}} archivo(s) al remoto…" }, "tool": { "running": "Ejecutando…", From ce1a9d4cdf8991e574c8d2ed5186d554c589c76d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:49:41 -0700 Subject: [PATCH 18/95] Extract web-host agent launch path to fix max-lines lint break on main (#8174) launch-agent-in-new-tab.ts crossed the 300-line oxlint max-lines limit (303 counted lines) after #5510 and #7944 both grew it; verify only runs on PRs, so the over-limit state landed on main via a merge race and now fails lint for every open PR. Move the web-runtime host launch branch (stale-local-tab pruning plus createWebRuntimeSessionTerminal call) into launch-agent-web-host-tab.ts. No behavior change; the i18n key is kept so locale catalogs are untouched. Co-authored-by: Brennan Benson --- .../src/lib/launch-agent-in-new-tab.ts | 58 ++------------ .../src/lib/launch-agent-web-host-tab.ts | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/lib/launch-agent-web-host-tab.ts diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 9ceca2e382a..52255ac16cf 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -14,11 +14,8 @@ import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mod import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { - createWebRuntimeSessionTerminal, - isWebRuntimeSessionActive, - isWebTerminalSurfaceTabId -} from '@/runtime/web-runtime-session' +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import { launchAgentInWebHostTab } from '@/lib/launch-agent-web-host-tab' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -58,15 +55,6 @@ export type LaunchAgentInNewTabArgs = { onPromptDelivered?: () => void } -function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { - const state = useAppStore.getState() - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) { - state.closeTab(tab.id) - } - } -} - export type LaunchAgentInNewTabResult = { tabId: string | null startupPlan: AgentStartupPlan @@ -215,44 +203,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { - // Why: paired web tabs are host-owned and return tabId: null on success. - // Local-only agent tabs cannot be closed because close routes through - // session.tabs.close on the host, so prune them before the host snapshot. - removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) - void createWebRuntimeSessionTerminal({ + launchAgentInWebHostTab({ + agent, worktreeId, environmentId: runtimeEnvironmentId, - targetGroupId: groupId, - activate: true, - ...(hasPrompt - ? { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}) - } - : { agent }) - }).then((created) => { - // Why: created means the host accepted the launch, not that a local tab - // exists; keep pruning stale local rows until the snapshot mirrors. - removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) - if (!created) { - toast.error( - translate( - 'auto.lib.launch.agent.in.new.tab.11cce5cc77', - 'Could not launch {{value0}} in a new terminal.', - { value0: agent } - ) - ) - return - } - store.setActiveTabType('terminal') - if (hasPrompt) { - onPromptDelivered?.() - } + groupId, + hasPrompt, + startupPlan, + onPromptDelivered }) return { tabId: null, startupPlan, pasteDraftAfterLaunch: false } } diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts new file mode 100644 index 00000000000..b95ccd8aba1 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -0,0 +1,75 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { + createWebRuntimeSessionTerminal, + isWebTerminalSurfaceTabId +} from '@/runtime/web-runtime-session' +import type { TuiAgent } from '../../../shared/types' +import { translate } from '@/i18n/i18n' + +function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { + const state = useAppStore.getState() + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) { + state.closeTab(tab.id) + } + } +} + +/** + * Launch an agent terminal on the web runtime host instead of a local tab. + * + * Why: paired web tabs are host-owned, so this path never creates a local tab + * (callers return tabId: null). Local-only agent tabs cannot be closed because + * close routes through session.tabs.close on the host, so prune them before + * the host snapshot. + */ +export function launchAgentInWebHostTab(args: { + agent: TuiAgent + worktreeId: string + environmentId: string | null + groupId?: string + hasPrompt: boolean + startupPlan: AgentStartupPlan + onPromptDelivered?: () => void +}): void { + const { agent, worktreeId, environmentId, groupId, hasPrompt, startupPlan, onPromptDelivered } = + args + removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) + void createWebRuntimeSessionTerminal({ + worktreeId, + environmentId, + targetGroupId: groupId, + activate: true, + ...(hasPrompt + ? { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: agent, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}) + } + : { agent }) + }).then((created) => { + // Why: created means the host accepted the launch, not that a local tab + // exists; keep pruning stale local rows until the snapshot mirrors. + removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) + if (!created) { + toast.error( + translate( + 'auto.lib.launch.agent.in.new.tab.11cce5cc77', + 'Could not launch {{value0}} in a new terminal.', + { value0: agent } + ) + ) + return + } + useAppStore.getState().setActiveTabType('terminal') + if (hasPrompt) { + onPromptDelivered?.() + } + }) +} From fe714211bafc9c6fe5b95852bfa67fb4638557bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:50:39 -0700 Subject: [PATCH 19/95] Restore original agent working-spinner size in AgentStateDot (#8172) PR #7423 resized the working spinner ring from the dot size to the icon size (sm: 6px -> 10px, md: 8px -> 12px) as a side effect of the AI Vault subagent display work. The size change was unintentional in the sidebar agent rows, so restore the original dot sizing. Co-authored-by: Brennan Benson --- src/renderer/src/components/AgentStateDot.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index c039907c1c3..951e3be36f1 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -76,11 +76,8 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - // Sized like the check icon (not the filled dots): both are - // outline glyphs, so a smaller ring reads visibly undersized - // next to a 'done' check in the same list. 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]', - icon + inner )} /> From 5489b975d8689b91b83e70cddce60d20fe0ae12c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:51:35 +0000 Subject: [PATCH 20/95] release: v1.4.135-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 28335274f70..4bae0747aae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.135-rc.1", + "version": "1.4.135-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 402274a35c8e41b402f6b0bba8127d77d7301fd2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:26:26 -0700 Subject: [PATCH 21/95] perf(renderer): scope editor Git subscriptions by worktree (#8107) --- .../src/components/editor/EditorPanel.tsx | 21 ++++-- .../editor-panel-git-entry-selector.test.ts | 74 +++++++++++++++++++ .../editor/editor-panel-git-entry-selector.ts | 17 +++++ .../editor/editor-panel-render-model.test.ts | 7 +- .../editor/editor-panel-render-model.ts | 12 +-- .../useEditorPanelContentState.test.tsx | 2 +- .../editor/useEditorPanelContentState.ts | 12 ++- 7 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts create mode 100644 src/renderer/src/components/editor/editor-panel-git-entry-selector.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 03fde8425c5..dd89fd7ab39 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -18,6 +18,10 @@ import { useEditorPanelContentState } from './useEditorPanelContentState' import { useMarkdownPreviewShortcut } from './useMarkdownPreviewShortcut' import { useUntitledFileRename } from './useUntitledFileRename' import { extractFrontMatter } from './markdown-frontmatter' +import { + selectEditorPanelGitBranchEntries, + selectEditorPanelGitStatusEntries +} from './editor-panel-git-entry-selector' function EditorPanelInner({ activeFileId: activeFileIdProp, @@ -33,10 +37,17 @@ function EditorPanelInner({ const activeFileId = activeFileIdProp ?? globalActiveFileId const activeViewStateId = activeViewStateIdProp ?? activeFileId const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null + const activeWorktreeId = activeFile?.worktreeId const markFileDirty = useAppStore((s) => s.markFileDirty) const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal) - const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) - const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree) + // Why: background Git refreshes for other worktrees must not wake every + // mounted Monaco/rich editor pane. + const gitStatusEntries = useAppStore((s) => + selectEditorPanelGitStatusEntries(s, activeWorktreeId) + ) + const gitBranchEntries = useAppStore((s) => + selectEditorPanelGitBranchEntries(s, activeWorktreeId) + ) const markdownViewMode = useAppStore((s) => s.markdownViewMode) const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode) const editorViewMode = useAppStore((s) => s.editorViewMode) @@ -96,7 +107,7 @@ function EditorPanelInner({ activeFile, isChangesMode: requestedChangesMode, openFiles, - gitStatusByWorktree, + gitStatusEntries, editorViewMode }) const isChangesMode = @@ -225,8 +236,8 @@ function EditorPanelInner({ activeFile, fileContents, editorDrafts, - gitStatusByWorktree, - gitBranchChangesByWorktree, + gitStatusEntries, + gitBranchEntries, markdownViewMode, isChangesMode }) diff --git a/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts b/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts new file mode 100644 index 00000000000..524776853d5 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-git-entry-selector.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' +import { + selectEditorPanelGitBranchEntries, + selectEditorPanelGitStatusEntries +} from './editor-panel-git-entry-selector' + +describe('editor panel Git entry selectors', () => { + it('ignore background worktree writes across mounted editor panels', () => { + const panelCount = 200 + const worktreeId = 'worktree-active' + const statusEntries = [{ path: 'src/index.ts' }] as GitStatusEntry[] + const branchEntries = [{ path: 'src/index.ts' }] as GitBranchChangeEntry[] + let gitStatusByWorktree = { [worktreeId]: statusEntries } + let gitBranchChangesByWorktree = { [worktreeId]: branchEntries } + let wholeMapInvalidations = 0 + let scopedEntryInvalidations = 0 + + for (let write = 0; write < 200; write += 1) { + const previousStatusMap = gitStatusByWorktree + gitStatusByWorktree = { + ...gitStatusByWorktree, + [`background-${write}`]: [{ path: `generated-${write}.ts` } as GitStatusEntry] + } + const previousBranchMap = gitBranchChangesByWorktree + gitBranchChangesByWorktree = { + ...gitBranchChangesByWorktree, + [`background-${write}`]: [{ path: `generated-${write}.ts` } as GitBranchChangeEntry] + } + + for (let panel = 0; panel < panelCount; panel += 1) { + wholeMapInvalidations += Number(previousStatusMap !== gitStatusByWorktree) + wholeMapInvalidations += Number(previousBranchMap !== gitBranchChangesByWorktree) + scopedEntryInvalidations += Number( + selectEditorPanelGitStatusEntries({ gitStatusByWorktree }, worktreeId) !== statusEntries + ) + scopedEntryInvalidations += Number( + selectEditorPanelGitBranchEntries({ gitBranchChangesByWorktree }, worktreeId) !== + branchEntries + ) + } + } + + expect(wholeMapInvalidations).toBe(80_000) + expect(scopedEntryInvalidations).toBe(0) + }) + + it('publishes owning entry replacements and handles an absent worktree', () => { + const firstStatus = [{ path: 'src/old.ts' }] as GitStatusEntry[] + const nextStatus = [{ path: 'src/new.ts' }] as GitStatusEntry[] + const firstBranch = [{ path: 'src/old.ts' }] as GitBranchChangeEntry[] + const nextBranch = [{ path: 'src/new.ts' }] as GitBranchChangeEntry[] + + expect( + selectEditorPanelGitStatusEntries({ gitStatusByWorktree: { active: firstStatus } }, 'active') + ).toBe(firstStatus) + expect( + selectEditorPanelGitStatusEntries({ gitStatusByWorktree: { active: nextStatus } }, 'active') + ).toBe(nextStatus) + expect( + selectEditorPanelGitBranchEntries( + { gitBranchChangesByWorktree: { active: firstBranch } }, + 'active' + ) + ).toBe(firstBranch) + expect( + selectEditorPanelGitBranchEntries( + { gitBranchChangesByWorktree: { active: nextBranch } }, + 'active' + ) + ).toBe(nextBranch) + expect(selectEditorPanelGitStatusEntries({ gitStatusByWorktree: {} }, null)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/editor-panel-git-entry-selector.ts b/src/renderer/src/components/editor/editor-panel-git-entry-selector.ts new file mode 100644 index 00000000000..a705c0d65aa --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-git-entry-selector.ts @@ -0,0 +1,17 @@ +import type { AppState } from '@/store' + +type EditorPanelGitEntryState = Pick + +export function selectEditorPanelGitStatusEntries( + state: Pick, + worktreeId: string | null | undefined +): AppState['gitStatusByWorktree'][string] | undefined { + return worktreeId ? state.gitStatusByWorktree[worktreeId] : undefined +} + +export function selectEditorPanelGitBranchEntries( + state: Pick, + worktreeId: string | null | undefined +): AppState['gitBranchChangesByWorktree'][string] | undefined { + return worktreeId ? state.gitBranchChangesByWorktree[worktreeId] : undefined +} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index 88e3a539e63..adbb6eafb9d 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -34,12 +34,13 @@ function renderModel(args: { isChangesMode?: boolean gitStatusByWorktree?: Record }) { + const activeFile = args.activeFile ?? markdownFile() return getEditorPanelRenderModel({ - activeFile: args.activeFile ?? markdownFile(), + activeFile, fileContents: args.fileContents ?? { '/repo/README.md': textContent() }, editorDrafts: args.editorDrafts ?? {}, - gitStatusByWorktree: args.gitStatusByWorktree ?? {}, - gitBranchChangesByWorktree: {}, + gitStatusEntries: args.gitStatusByWorktree?.[activeFile.worktreeId], + gitBranchEntries: undefined, markdownViewMode: args.markdownViewMode ?? {}, isChangesMode: args.isChangesMode ?? false }) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index c471b2e73ad..901c694fad7 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -22,8 +22,8 @@ type EditorPanelRenderModelParams = { activeFile: OpenFile fileContents: Record editorDrafts: StoreState['editorDrafts'] - gitStatusByWorktree: StoreState['gitStatusByWorktree'] - gitBranchChangesByWorktree: StoreState['gitBranchChangesByWorktree'] + gitStatusEntries: StoreState['gitStatusByWorktree'][string] | undefined + gitBranchEntries: StoreState['gitBranchChangesByWorktree'][string] | undefined markdownViewMode: StoreState['markdownViewMode'] isChangesMode: boolean } @@ -32,8 +32,8 @@ export function getEditorPanelRenderModel({ activeFile, fileContents, editorDrafts, - gitStatusByWorktree, - gitBranchChangesByWorktree, + gitStatusEntries, + gitBranchEntries, markdownViewMode, isChangesMode }: EditorPanelRenderModelParams) { @@ -54,8 +54,8 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' ? detectLanguage(activeFile.relativePath) : detectLanguage(activeFile.filePath) - const worktreeEntries = gitStatusByWorktree[activeFile.worktreeId] ?? [] - const branchEntries = gitBranchChangesByWorktree[activeFile.worktreeId] ?? [] + const worktreeEntries = gitStatusEntries ?? [] + const branchEntries = gitBranchEntries ?? [] const matchingWorktreeEntry = activeFile.mode === 'diff' && (activeFile.diffSource === 'staged' || activeFile.diffSource === 'unstaged') diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 90807e66cc7..4ac009d24f1 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -101,7 +101,7 @@ function HookProbe({ activeFile, isChangesMode: false, openFiles, - gitStatusByWorktree, + gitStatusEntries: activeFile ? gitStatusByWorktree[activeFile.worktreeId] : undefined, editorViewMode: {} }) latestFileContents = state.fileContents diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index f4f668a5a4c..6e60afbf833 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -41,7 +41,7 @@ type UseEditorPanelContentStateParams = { activeFile: OpenFile | null isChangesMode: boolean openFiles: OpenFile[] - gitStatusByWorktree: GitStatusByWorktree + gitStatusEntries: GitStatusByWorktree[string] | undefined editorViewMode: EditorViewModeByFile } @@ -94,7 +94,7 @@ export function useEditorPanelContentState({ activeFile, isChangesMode, openFiles, - gitStatusByWorktree, + gitStatusEntries, editorViewMode }: UseEditorPanelContentStateParams): UseEditorPanelContentStateResult { const [fileContents, setFileContents] = useState>({}) @@ -362,7 +362,7 @@ export function useEditorPanelContentState({ } const snapshotPaths = new Set(snapshotEntries.map((entry) => entry.path)) - const liveEntries = gitStatusByWorktree[activeFile.worktreeId] ?? [] + const liveEntries = gitStatusEntries ?? [] for (const entry of liveEntries) { if ( !snapshotPaths.has(entry.path) || @@ -411,7 +411,7 @@ export function useEditorPanelContentState({ activeFile?.conflictReview?.snapshotTimestamp, selectedConflictReviewFile?.id, isChangesMode, - gitStatusByWorktree + gitStatusEntries ]) useEditorPanelFileLoadRetry({ @@ -423,9 +423,7 @@ export function useEditorPanelContentState({ setFileContents }) - const changesStatusEntries = activeFile?.worktreeId - ? gitStatusByWorktree[activeFile.worktreeId] - : undefined + const changesStatusEntries = activeFile?.worktreeId ? gitStatusEntries : undefined const activeFileGitStatusEntries = useMemo(() => { if (!activeFile?.relativePath || !changesStatusEntries) { return undefined From bd796ad362bbac61f82e9ac8003fd00ceecc9b32 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:27:40 -0700 Subject: [PATCH 22/95] Bump mobile app.json to 0.0.26 (#8046) Co-authored-by: Orca --- mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/app.json b/mobile/app.json index d5844ff527a..db8326a3dbc 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.25", + "version": "0.0.26", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", From c633a634ba115932486f7a8f95e154df8d7a4b3e Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:32:24 -0300 Subject: [PATCH 23/95] fix: handle headless file download version skew (#7738) * fix: handle headless file download version skew - Issue: remote downloads could open the local save dialog before discovering an older headless server lacks files.readChunk. - Fix: preflight the first remote chunk before starting the local download transaction. - Coverage: add regression coverage for method_not_found and preserve cleanup for mid-transfer failures. * fix: fall back for headless downloads without chunks - Issue: older headless servers can lack files.readChunk but still expose preview-sized file reads. - Fix: fall back to files.readPreview and save the file when the preview payload is usable. - Coverage: verify preview fallback success, unsupported preview update messaging, and modern chunk cleanup. * fix: reduce remote download support probe - Issue: the chunk-support probe downloaded a full chunk before the real save flow started. - Fix: probe chunk support with a one-byte read and keep the actual download starting from offset zero. - Coverage: add a transport-failure probe test and name the remote download args type. * fix: preserve empty binary preview downloads Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../src/runtime/runtime-file-client.test.ts | 178 +++++++++++++++++- .../src/runtime/runtime-file-client.ts | 103 ++++++++-- 2 files changed, 268 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 90366acbfa4..7b80f59f5b9 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -522,6 +522,12 @@ describe('runtime file client', () => { destinationPath: '/downloads/archive.zip' }) runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'preflight', + ok: true, + result: { contentBase64: 'YWJj', bytesRead: 3, eof: false }, + _meta: { runtimeId: 'remote-runtime' } + }) .mockResolvedValueOnce({ id: 'chunk-1', ok: true, @@ -555,11 +561,22 @@ describe('runtime file client', () => { worktree: 'id:wt-1', relativePath: 'archive.zip', offset: 0, - length: 384 * 1024 + length: 1 }, timeoutMs: 60_000 }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.readChunk', + params: { + worktree: 'id:wt-1', + relativePath: 'archive.zip', + offset: 0, + length: 384 * 1024 + }, + timeoutMs: 60_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { selector: 'env-1', method: 'files.readChunk', params: { @@ -575,6 +592,150 @@ describe('runtime file client', () => { expect(fsCancelDownloadedFile).not.toHaveBeenCalled() }) + it('does not open the save dialog when the remote chunk probe fails for transport reasons', async () => { + runtimeEnvironmentCall.mockRejectedValueOnce(new Error('connection dropped')) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/archive.zip', + 'archive.zip' + ) + ).rejects.toThrow('connection dropped') + + expect(fsStartDownloadedFile).not.toHaveBeenCalled() + expect(fsSaveDownloadedFile).not.toHaveBeenCalled() + }) + + it('falls back to preview content when older remote runtimes lack chunked download', async () => { + fsSaveDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/report.txt' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method: files.readChunk' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'preview-1', + ok: true, + result: { content: 'hello\n', isBinary: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/report.txt', + 'report.txt' + ) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.txt' }) + + expect(fsStartDownloadedFile).not.toHaveBeenCalled() + expect(fsSaveDownloadedFile).toHaveBeenCalledWith({ + suggestedName: 'report.txt', + content: 'hello\n', + encoding: 'utf8' + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'files.readPreview', + params: { worktree: 'id:wt-1', relativePath: 'report.txt' }, + timeoutMs: 15_000 + }) + expect(fsCancelDownloadedFile).not.toHaveBeenCalled() + }) + + it('downloads a complete zero-byte recognized binary from an older remote runtime', async () => { + fsSaveDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/empty.png' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method: files.readChunk' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'preview-1', + ok: true, + result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/empty.png', + 'empty.png' + ) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/empty.png' }) + + expect(fsStartDownloadedFile).not.toHaveBeenCalled() + expect(fsSaveDownloadedFile).toHaveBeenCalledWith({ + suggestedName: 'empty.png', + content: '', + encoding: 'base64' + }) + }) + + it('asks users to update older remote runtimes when preview fallback cannot download the file', async () => { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method: files.readChunk' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'preview-1', + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + downloadRuntimeFile( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + '/remote/repo/archive.zip', + 'archive.zip' + ) + ).rejects.toThrow('Remote file download requires a newer Orca server') + + expect(fsStartDownloadedFile).not.toHaveBeenCalled() + expect(fsSaveDownloadedFile).not.toHaveBeenCalled() + }) + it('cancels the local temp download when a remote chunk fails', async () => { fsStartDownloadedFile.mockResolvedValue({ canceled: false, @@ -582,7 +743,20 @@ describe('runtime file client', () => { destinationPath: '/downloads/archive.zip' }) fsCancelDownloadedFile.mockResolvedValue({ ok: true }) - runtimeEnvironmentCall.mockRejectedValueOnce(new Error('connection dropped')) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'preflight', + ok: true, + result: { contentBase64: 'YWJj', bytesRead: 3, eof: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: true, + result: { contentBase64: 'YWJj', bytesRead: 3, eof: false }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockRejectedValueOnce(new Error('connection dropped')) await expect( downloadRuntimeFile( diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index eddda11a6c1..d8738600d69 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -103,6 +103,10 @@ type RuntimeFileWatchEvent = const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 +const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = + 'Remote file download requires a newer Orca server. Update the headless server and try again.' + +type RemoteFileDownloadArgs = NonNullable> type RuntimeFileWatchListener = { onPayload: (payload: FsChangedPayload) => void @@ -227,6 +231,10 @@ export async function downloadRuntimeFile( }) } + if (!(await remoteChunkedDownloadAvailable(remoteArgs))) { + return downloadRemoteFileViaPreview(remoteArgs, suggestedName) + } + const download = await window.api.fs.startDownloadedFile({ suggestedName }) if (download.canceled) { return download @@ -236,17 +244,7 @@ export async function downloadRuntimeFile( try { let offset = 0 for (;;) { - const chunk = await callRuntimeRpc( - remoteArgs.target, - 'files.readChunk', - { - worktree: remoteArgs.worktreeSelector, - relativePath: remoteArgs.relativePath, - offset, - length: REMOTE_DOWNLOAD_CHUNK_BYTES - }, - { timeoutMs: 60_000 } - ) + const chunk = await readRemoteDownloadChunk(remoteArgs, offset) if (chunk.bytesRead > 0) { await window.api.fs.appendDownloadedFileChunk({ transferId: download.transferId, @@ -271,6 +269,89 @@ export async function downloadRuntimeFile( } } +async function remoteChunkedDownloadAvailable( + remoteArgs: RemoteFileDownloadArgs +): Promise { + try { + await callRuntimeRpc( + remoteArgs.target, + 'files.readChunk', + { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + offset: 0, + length: 1 + }, + { timeoutMs: 60_000 } + ) + return true + } catch (error) { + // Why: compatible older headless servers may lack chunked downloads while + // still supporting preview-sized file reads that can complete the request. + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return false + } + throw error + } +} + +async function readRemoteDownloadChunk( + remoteArgs: RemoteFileDownloadArgs, + offset: number +): Promise { + return callRuntimeRpc( + remoteArgs.target, + 'files.readChunk', + { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + offset, + length: REMOTE_DOWNLOAD_CHUNK_BYTES + }, + { timeoutMs: 60_000 } + ) +} + +async function downloadRemoteFileViaPreview( + remoteArgs: RemoteFileDownloadArgs, + suggestedName: string +): Promise { + try { + const result = await callRuntimeRpc( + remoteArgs.target, + 'files.readPreview', + { worktree: remoteArgs.worktreeSelector, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) + // Why: old servers use an empty, metadata-free binary result to signal an + // unsupported binary; recognized zero-byte previews are still complete. + if (result.isBinary && !result.content && !result.isImage && !result.mimeType) { + throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) + } + return window.api.fs.saveDownloadedFile({ + suggestedName, + content: result.content, + encoding: result.isBinary ? 'base64' : 'utf8' + }) + } catch (error) { + if (isUnsupportedRemotePreviewDownload(error)) { + throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) + } + throw error + } +} + +function isUnsupportedRemotePreviewDownload(error: unknown): boolean { + if (!(error instanceof RuntimeRpcCallError)) { + return false + } + return ( + error.code === 'method_not_found' || + (error.code === 'runtime_error' && + (error.message === 'file_too_large' || error.message === 'binary_file')) + ) +} + export async function readRuntimeDirectory( context: RuntimeFileOperationArgs, dirPath: string From 8976bd969cd58af8ef0efce9e1fa9adeaa310c54 Mon Sep 17 00:00:00 2001 From: Tim Suchanek Date: Fri, 10 Jul 2026 16:55:06 -0700 Subject: [PATCH 24/95] Fix SSH worktree delete routing after host reconnect (#7997) * Fix SSH worktree delete routing * Harden host-scoped worktree deletion Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/ipc/worktrees.test.ts | 177 +++++++++++++++++- src/main/ipc/worktrees.ts | 169 +++++++++++------ src/preload/api-types.ts | 8 +- src/preload/index.ts | 2 + .../sidebar/delete-worktree-flow.ts | 8 +- .../src/lib/ensure-hooks-confirmed.test.ts | 22 +++ .../src/lib/ensure-hooks-confirmed.ts | 42 ++++- .../src/runtime/runtime-hooks-client.ts | 6 +- .../src/store/slices/worktrees.test.ts | 34 ++++ src/renderer/src/store/slices/worktrees.ts | 67 ++++++- 10 files changed, 458 insertions(+), 77 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 7b4f699cd62..509c7243eca 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6935,6 +6935,169 @@ describe('registerWorktreeHandlers', () => { expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) }) + it('uses the workspace host when duplicate repo ids exist across local and SSH', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + } + const sshRepo = { + ...localRepo, + path: '/remote/repo', + displayName: 'ssh', + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(localRepo) + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt', + hostId: 'ssh:conn-1' + }) + + expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('fails closed when duplicate repo ids are deleted without a host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-shared::/remote/feature-wt' + }) + ).rejects.toThrow('Repo not found: repo-shared') + + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('inspects hooks on the requested host when repo ids collide', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n archive: remote-cleanup', + isBinary: false + }) + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + + await expect( + handlers['hooks:check'](null, { + repoId: 'repo-shared', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({ + status: 'ok', + hasHooks: true, + hooks: { scripts: { archive: 'remote-cleanup' } }, + mayNeedUpdate: false + }) + expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('fails hook inspection closed when duplicate repo ids omit the host', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + + await expect(handlers['hooks:check'](null, { repoId: 'repo-shared' })).resolves.toEqual({ + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(hasHooksFileMock).not.toHaveBeenCalled() + }) + + it('does not coalesce forget requests for the same id on different hosts', async () => { + const localRepo = { + id: 'repo-shared', + path: '/local/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + let finishFirst!: () => void + killAllProcessesForWorktreeMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => + resolve({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + }) + ) + .mockResolvedValueOnce({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + const first = handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'local' + }) as Promise + await vi.waitFor(() => expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(1)) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-shared::/same/path', + hostId: 'ssh:conn-1' + }) + ).resolves.toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledTimes(2) + + finishFirst() + await expect(first).resolves.toEqual({}) + }) + it('preserves the branch on remove for worktrees created from an existing local branch', async () => { mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) @@ -7257,14 +7420,16 @@ describe('registerWorktreeHandlers', () => { await mkdir(adminWorktreePath, { recursive: true }) await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) - store.getRepo.mockReturnValue({ + const repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '#000', addedAt: 0, worktreeBaseRef: null - }) + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ createdAt: Date.now() })) @@ -7298,14 +7463,16 @@ describe('registerWorktreeHandlers', () => { await mkdir(adminWorktreePath, { recursive: true }) await writeFile(join(orphanPath, '.git'), `gitdir: ${adminWorktreePath}\n`) await writeFile(join(adminWorktreePath, 'gitdir'), `${join(orphanPath, '.git')}\n`) - store.getRepo.mockReturnValue({ + const repo = { id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '#000', addedAt: 0, worktreeBaseRef: null - }) + } + store.getRepo.mockReturnValue(repo) + store.getRepos.mockReturnValue([repo]) mockKnownFeatureWorktree(join(parentDir, 'real-feature'), repoPath) store.getWorktreeMeta.mockReturnValue( makeWorktreeMeta({ orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' }) @@ -7527,6 +7694,7 @@ describe('registerWorktreeHandlers', () => { }) as Promise const second = handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt', + hostId: 'local', force: true }) as Promise @@ -7564,6 +7732,7 @@ describe('registerWorktreeHandlers', () => { await expect( handlers['worktrees:remove'](null, { worktreeId: 'repo-1::/workspace/feature-wt', + hostId: 'local', force: true }) ).rejects.toThrow('Worktree deletion already in progress') diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index d3ed835646a..8f4e64e4207 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -29,6 +29,7 @@ import type { Worktree, WorktreeMeta } from '../../shared/types' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, @@ -101,6 +102,35 @@ import { type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance } + +type RemoveWorktreeArgs = { + worktreeId: string + hostId?: ExecutionHostId + force?: boolean + skipArchive?: boolean +} + +function getRepoForWorktreeRemoval( + store: Store, + repoId: string, + hostId?: ExecutionHostId +): Repo | undefined { + const matches = store + .getRepos() + .filter((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId)) + // Why: deletion must never guess between host owners. Legacy unscoped calls + // remain compatible only while the repo id still has one unique owner. + if (matches.length === 1) { + return matches[0] + } + if (matches.length > 1) { + return undefined + } + const legacyMatch = store.getRepo(repoId) + return legacyMatch && (!hostId || getRepoExecutionHostId(legacyMatch) === hostId) + ? legacyMatch + : undefined +} import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' @@ -300,6 +330,10 @@ function getWorktreeRemovalOptionsKey(args: { force?: boolean; skipArchive?: boo return `${forceKey}:${archiveKey}` } +function getWorktreeRemovalInFlightKey(worktreeId: string, hostId?: ExecutionHostId): string { + return `${hostId ?? ''}\0${worktreeId}` +} + async function getArchiveHooksForRemoval(repo: Repo): Promise { if (!repo.connectionId) { return getEffectiveHooks(repo) @@ -1345,9 +1379,18 @@ export function registerWorktreeHandlers( ipcMain.handle( 'worktrees:remove', - async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => { + async (_event, args: RemoveWorktreeArgs): Promise => { + const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) + const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + const inFlightKey = getWorktreeRemovalInFlightKey( + args.worktreeId, + getRepoExecutionHostId(repo) + ) const optionsKey = getWorktreeRemovalOptionsKey(args) - const inFlightRemoval = worktreeRemovalsInFlight.get(args.worktreeId) + const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) if (inFlightRemoval) { if (inFlightRemoval.optionsKey === optionsKey) { return inFlightRemoval.promise @@ -1359,11 +1402,6 @@ export function registerWorktreeHandlers( // target the same worktree concurrently. Share the destructive backend // operation so only one path touches Git and the filesystem. const removal = (async (): Promise => { - const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) - const repo = store.getRepo(repoId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } if (isFolderRepo(repo)) { if (args.worktreeId === getFolderWorkspaceRootId(repo)) { throw new Error( @@ -1797,12 +1835,12 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} })() - worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: removal }) + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { return await removal } finally { - if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === removal) { - worktreeRemovalsInFlight.delete(args.worktreeId) + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { + worktreeRemovalsInFlight.delete(inFlightKey) } } } @@ -1816,12 +1854,24 @@ export function registerWorktreeHandlers( // no branches, no files are deleted there. ipcMain.handle( 'worktrees:forgetLocal', - async (_event, args: { worktreeId: string }): Promise => { + async ( + _event, + args: Pick + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } // Why: share the removal in-flight map (not a separate one) so a concurrent // worktrees:remove and worktrees:forgetLocal on the same id cannot both // mutate metadata. A forget takes no force/skipArchive options. + const inFlightKey = getWorktreeRemovalInFlightKey( + args.worktreeId, + getRepoExecutionHostId(repo) + ) const optionsKey = 'forget-local' - const inFlight = worktreeRemovalsInFlight.get(args.worktreeId) + const inFlight = worktreeRemovalsInFlight.get(inFlightKey) if (inFlight) { if (inFlight.optionsKey === optionsKey) { return inFlight.promise @@ -1830,11 +1880,6 @@ export function registerWorktreeHandlers( } const forget = (async (): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const repo = store.getRepo(repoId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' @@ -1858,12 +1903,12 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return {} })() - worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: forget }) + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) try { return await forget } finally { - if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === forget) { - worktreeRemovalsInFlight.delete(args.worktreeId) + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { + worktreeRemovalsInFlight.delete(inFlightKey) } } } @@ -1996,49 +2041,63 @@ export function registerWorktreeHandlers( } }) - ipcMain.handle('hooks:check', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo || isFolderRepo(repo)) { - return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + ipcMain.handle( + 'hooks:check', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) + if (!repo) { + const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) + // Why: a requested or ambiguous host must not be reported as hook-free; + // callers treat inspection errors as "skip", which keeps hook execution fail closed. return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', + status: args.hostId || repoIdExists ? 'error' : 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } } - } + if (isFolderRepo(repo)) { + return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } + } - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - // Why: when a newer Orca version adds a top-level key to `orca.yaml`, older - // versions that don't recognise it return null and show "could not be parsed". - // Detecting well-formed but unrecognised keys lets the UI suggest updating - // instead of implying the file is broken. - const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - return { - status: 'ok', - hasHooks: has, - hooks, - mayNeedUpdate + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + return { + status: 'ok', + hasHooks: !result.isBinary, + hooks: result.isBinary ? null : parseOrcaYaml(result.content), + mayNeedUpdate: false + } + } catch (error) { + return { + status: isENOENT(error) ? 'ok' : 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + // Why: when a newer Orca version adds a top-level key to `orca.yaml`, older + // versions that don't recognise it return null and show "could not be parsed". + // Detecting well-formed but unrecognised keys lets the UI suggest updating + // instead of implying the file is broken. + const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + return { + status: 'ok', + hasHooks: has, + hooks, + mayNeedUpdate + } } - }) + ) ipcMain.handle( 'hooks:createIssueCommandRunner', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 100b89b97f4..e278d863e21 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1130,12 +1130,16 @@ export type PreloadApi = { > remove: (args: { worktreeId: string + hostId?: ExecutionHostId force?: boolean skipArchive?: boolean }) => Promise // Forget a workspace from Orca only — no remote Git/filesystem work. Used // for workspaces pinned to a removed/disconnected SSH host. - forgetLocal: (args: { worktreeId: string }) => Promise + forgetLocal: (args: { + worktreeId: string + hostId?: ExecutionHostId + }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string branchName: string @@ -2131,7 +2135,7 @@ export type PreloadApi = { browser: BrowserApi emulator: EmulatorApi hooks: { - check: (args: { repoId: string }) => Promise<{ + check: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ status?: 'ok' | 'error' hasHooks: boolean hooks: OrcaHooks | null diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f63a71d985..8f24dc18217 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -142,6 +142,7 @@ import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { PreflightRuntimeContext, RefreshAgentsResult } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' +import type { ExecutionHostId } from '../shared/execution-host' import type { Automation, AutomationCreateInput, @@ -2478,6 +2479,7 @@ const api = { hooks: { check: (args: { repoId: string + hostId?: ExecutionHostId }): Promise<{ status?: 'ok' | 'error' hasHooks: boolean diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index cac60ca3727..02ff447dfc1 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -1,6 +1,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' +import { findRepoForHost } from '@/store/slices/repo-host-identity' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' @@ -262,7 +263,12 @@ export function runWorktreeDelete(worktreeId: string): void { // misclassify every SSH repo as a ghost, routing to a forget dialog whose // local-only backend is unavailable there. Their normal worktree.rm RPC path // already handles the delete against the desktop runtime. - const repo = state.repos.find((entry) => entry.id === target.repoId) ?? null + const matchingRepos = state.repos.filter((entry) => entry.id === target.repoId) + const repo = target.hostId + ? findRepoForHost(matchingRepos, target.repoId, { hostId: target.hostId }) + : matchingRepos.length === 1 + ? matchingRepos[0] + : null const sshResolution = isPairedWebClientWindow() ? { kind: 'not-ssh' as const } : resolveSshWorkspaceForget({ diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index e7367b0063a..da3d2028ab2 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -225,6 +225,28 @@ describe('ensureHooksConfirmed', () => { expect(pending).toHaveLength(0) }) + it('inspects the requested host when duplicate repo ids exist', async () => { + const { state } = createTestState({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 1 } } }, + repos: [ + { id: 'repo-1', displayName: 'Runtime', executionHostId: 'runtime:env-1' }, + { id: 'repo-1', displayName: 'SSH', connectionId: 'ssh-1' } + ] + } as unknown as Partial) + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { scripts: {} }, + mayNeedUpdate: false + }) + + const decision = await ensureHooksConfirmed(state, 'repo-1', 'archive', 'ssh:ssh-1') + + expect(decision).toBe('run') + expect(hooksCheckMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:ssh-1' }) + expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled() + }) + it('checks runtime-owned repo hooks through the repo owner runtime', async () => { const { state, pending } = createTestState({ settings: { activeRuntimeEnvironmentId: 'focused-env' }, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index 856b45378bc..1ad0b7a7a77 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -4,6 +4,11 @@ import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-sou import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust' import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import { getRuntimeEnvironmentIdForRepo } from './repo-runtime-owner' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' export type HookScriptKind = OrcaHookScriptKind @@ -56,8 +61,23 @@ function getVmRecipeTrustContent(yamlHooks: OrcaHooks | null): string { .join('\n\n') } -function settingsForHookRepoOwner(state: AppState, repoId: string): AppState['settings'] { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForRepo(state, repoId) +function findHookRepo(state: AppState, repoId: string, hostId?: ExecutionHostId) { + return hostId + ? state.repos.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) + : state.repos.find((repo) => repo.id === repoId) +} + +function settingsForHookRepoOwner( + state: AppState, + repoId: string, + hostId?: ExecutionHostId +): AppState['settings'] { + const parsedHost = hostId ? parseExecutionHostId(hostId) : null + const runtimeEnvironmentId = hostId + ? parsedHost?.kind === 'runtime' + ? parsedHost.environmentId + : null + : getRuntimeEnvironmentIdForRepo(state, repoId) // Why: hook inspection must follow the repo owner. SSH/local repos execute // through desktop IPC, while runtime repos may differ from the focused host. return state.settings @@ -68,10 +88,12 @@ function settingsForHookRepoOwner(state: AppState, repoId: string): AppState['se export async function ensureHooksConfirmed( state: AppState, repoId: string, - scriptKind: HookScriptKind + scriptKind: HookScriptKind, + hostId?: ExecutionHostId ): Promise<'run' | 'skip'> { return enqueueTrustPrompt(async () => { - if (state.trustedOrcaHooks[repoId]?.all) { + const hasDuplicateRepoId = state.repos.filter((repo) => repo.id === repoId).length > 1 + if (state.trustedOrcaHooks[repoId]?.all && !(hostId && hasDuplicateRepoId)) { return 'run' } @@ -80,7 +102,7 @@ export async function ensureHooksConfirmed( if (scriptKind === 'issueCommand') { // Local overrides are user-owned; only shared orca.yaml commands need repo trust. const result = await readRuntimeIssueCommand( - settingsForHookRepoOwner(state, repoId), + settingsForHookRepoOwner(state, repoId, hostId), repoId ) if (result.source === 'local') { @@ -94,7 +116,7 @@ export async function ensureHooksConfirmed( } scriptContent = (result.sharedContent ?? '').trim() } else { - const repo = state.repos.find((r) => r.id === repoId) + const repo = findHookRepo(state, repoId, hostId) const localScript = repo?.hookSettings?.scripts?.[scriptKind]?.trim() const sourcePolicy = resolveHookCommandSourcePolicy( repo?.hookSettings?.commandSourcePolicy, @@ -105,7 +127,11 @@ export async function ensureHooksConfirmed( if (sourcePolicy === 'local-only') { return 'run' } - const result = await checkRuntimeHooks(settingsForHookRepoOwner(state, repoId), repoId) + const result = await checkRuntimeHooks( + settingsForHookRepoOwner(state, repoId, hostId), + repoId, + hostId + ) if (result.status === 'error') { return 'skip' } @@ -132,7 +158,7 @@ export async function ensureHooksConfirmed( return 'run' } - const repo = state.repos.find((r) => r.id === repoId) + const repo = findHookRepo(state, repoId, hostId) const repoName = repo?.displayName ?? 'this repository' // A non-empty existingHash that didn't match means the user approved a previous // version of this script; the prompt is reappearing because orca.yaml changed. diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts index 3d372188481..eff6f52f5b6 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -1,4 +1,5 @@ import type { GlobalSettings, OrcaHooks } from '../../../shared/types' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../../shared/setup-script-imports' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' @@ -20,11 +21,12 @@ export type IssueCommandReadResult = { export async function checkRuntimeHooks( settings: Pick | null | undefined, - repoId: string + repoId: string, + hostId?: ExecutionHostId ): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { - return window.api.hooks.check({ repoId }) + return window.api.hooks.check({ repoId, ...(hostId ? { hostId } : {}) }) } return callRuntimeRpc( target, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 8929f57594c..a3f85eec42c 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4101,6 +4101,7 @@ describe('worktree remote runtime mutations', () => { expect(result).toEqual({ ok: true }) expect(mockApi.worktrees.remove).toHaveBeenCalledWith({ worktreeId: wt.id, + hostId: 'ssh:ssh-1', force: undefined, skipArchive: false }) @@ -4108,6 +4109,39 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) }) + it('fails closed before deleting an exact worktree id owned by multiple hosts', async () => { + const store = createTestStore() + const worktreeId = 'repo-shared::/same/path' + store.setState({ + repos: [ + { id: 'repo-shared', path: '/local', displayName: 'Local', badgeColor: '#000', addedAt: 0 }, + { + id: 'repo-shared', + path: '/remote', + displayName: 'SSH', + badgeColor: '#111', + addedAt: 1, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { + 'repo-shared': [ + makeWorktree({ id: worktreeId, repoId: 'repo-shared', hostId: 'local' }), + makeWorktree({ id: worktreeId, repoId: 'repo-shared', hostId: 'ssh:ssh-1' }) + ] + } + } as Partial) + + const result = await store.getState().removeWorktree(worktreeId) + + expect(result).toEqual({ + ok: false, + error: 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' + }) + expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('persists worktree metadata through the active remote runtime environment', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d6fb11a315b..f459f6f641e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,8 @@ export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list // UI hydration parity this slice used before `worktree.detectedList` existed. const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 +const WORKTREE_REMOVAL_AMBIGUOUS_ERROR = + 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 @@ -1158,6 +1160,41 @@ function getWorktreeHostId( return repo ? getRepoExecutionHostId(repo) : null } +function resolveWorktreeRemovalHost( + state: Pick, + worktreeId: string +): { hostId: ExecutionHostId | null; ambiguous: boolean } { + const hostIds = new Set() + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId && worktree.hostId) { + hostIds.add(worktree.hostId) + } + } + } + if (hostIds.size > 1) { + return { hostId: null, ambiguous: true } + } + if (hostIds.size === 1) { + return { hostId: hostIds.values().next().value ?? null, ambiguous: false } + } + + const repoId = getRepoIdFromWorktreeId(worktreeId) + const repoHostIds = new Set( + state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId) + ) + return repoHostIds.size > 1 + ? { hostId: null, ambiguous: true } + : { hostId: repoHostIds.values().next().value ?? null, ambiguous: false } +} + function mergeLineageForHost( state: Pick< AppState, @@ -3145,6 +3182,11 @@ export const createWorktreeSlice: StateCreator }, removeWorktree: async (worktreeId, force, options) => { + const removalOwner = resolveWorktreeRemovalHost(get(), worktreeId) + if (removalOwner.ambiguous) { + return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } + } + const hostId = removalOwner.hostId ?? undefined const forgetLocalOnly = options?.mode === 'forget-local' set((s) => ({ deleteStateByWorktreeId: { @@ -3163,20 +3205,35 @@ export const createWorktreeSlice: StateCreator // and no need to prompt for hook trust. const skipArchive = forgetLocalOnly ? true - : (await ensureHooksConfirmed(get(), getRepoIdFromWorktreeId(worktreeId), 'archive')) === - 'skip' + : (await ensureHooksConfirmed( + get(), + getRepoIdFromWorktreeId(worktreeId), + 'archive', + hostId + )) === 'skip' const worktreeBeforeRemoval = get() .allWorktrees() .find((entry) => entry.id === worktreeId) + const currentOwner = resolveWorktreeRemovalHost(get(), worktreeId) + if ( + currentOwner.ambiguous || + (hostId && currentOwner.hostId && currentOwner.hostId !== hostId) + ) { + throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + } // Why: forget-local always clears Orca's own records via the local IPC // handler regardless of the workspace's execution host — the whole point // is that the remote (SSH relay / runtime) is gone or unreachable. - const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + const target = getActiveRuntimeTarget( + hostId + ? settingsForExecutionHostOwner(get().settings, hostId) + : settingsForWorktreeOwner(get(), worktreeId) + ) const removalResult = await (forgetLocalOnly - ? window.api.worktrees.forgetLocal({ worktreeId }) + ? window.api.worktrees.forgetLocal({ worktreeId, hostId }) : target.kind === 'local' - ? window.api.worktrees.remove({ worktreeId, force, skipArchive }) + ? window.api.worktrees.remove({ worktreeId, hostId, force, skipArchive }) : callRuntimeRpc( target, 'worktree.rm', From 1ee5babc8c5cce0799a32e755022e0a8a0dd9260 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:07:57 -0700 Subject: [PATCH 25/95] Fix mobile duplicate-PTY-handle crash after agent sleep + wake (#8016) Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 312 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 51 ++++- 2 files changed, 359 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 157442e548b..73330a14fa5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1625,6 +1625,318 @@ describe('OrcaRuntimeService', () => { }) }) + it('drops a stale leaf when a woken agent PTY is re-keyed to a new leaf on renderer reload', async () => { + const runtime = createRuntime() + const tabId = 'tab-1' + // Why: the agent holds a pre-allocated ORCA_TERMINAL_HANDLE, so its PTY has a + // handleByPtyId entry — the exact condition the reload preservation loop keys on. + runtime.preAllocateHandleForPty('pty-agent') + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'leaf-old', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-old', + paneRuntimeId: 1, + ptyId: 'pty-agent' + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(before.terminals).toHaveLength(1) + + // Simulate agent sleep + mobile wake: the renderer reloads and cold-restores + // the pane under a NEW leafId while the SAME agent PTY stays live. + runtime.markRendererReloading(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'leaf-new', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-new', + paneRuntimeId: 2, + ptyId: 'pty-agent' + } + ] + }) + + const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + // Before the fix the stale leaf-old survived beside leaf-new — two leaves on + // ONE PTY — so both adopted the same ptyId-keyed handle and paired clients + // crashed with a duplicate React key (`term_…`). Now only the live leaf remains. + expect(after.terminals).toHaveLength(1) + expect(after.terminals[0].ptyId).toBe('pty-agent') + // The shared handle must NOT have been invalidated — it belongs to leaf-new now. + await expect(runtime.showTerminal(after.terminals[0].handle)).resolves.toMatchObject({ + ptyId: 'pty-agent' + }) + }) + + it('still preserves a CLI agent leaf when the reloaded renderer has not rebound its PTY', async () => { + const runtime = createRuntime() + const tabId = 'tab-1' + runtime.preAllocateHandleForPty('pty-agent') + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'leaf-old', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-old', + paneRuntimeId: 1, + ptyId: 'pty-agent' + } + ] + }) + expect((await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).terminals).toHaveLength(1) + + // Renderer reloads but has NOT yet rebound the pane (empty graph). The live + // CLI agent PTY + exported handle must survive (the original preserve intent). + runtime.markRendererReloading(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [], leaves: [] }) + + const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(after.terminals).toHaveLength(1) + expect(after.terminals[0].ptyId).toBe('pty-agent') + }) + + it('invalidates a re-keyed leaf-unique handle so in-flight waiters fail fast', async () => { + const runtime = createRuntime() + const tabId = 'tab-1' + // No preAllocateHandleForPty: a plain terminal's handle is leaf-unique, so a + // re-key leaves it with no next owner and it must go stale immediately. + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Shell', + activeLeafId: 'leaf-old', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-old', + paneRuntimeId: 1, + ptyId: 'pty-plain' + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(before.terminals).toHaveLength(1) + const staleHandle = before.terminals[0].handle + const waiting = runtime.waitForTerminal(staleHandle, { condition: 'exit', timeoutMs: 30_000 }) + + // Re-key WITHOUT a renderer reload (e.g. a pane moved across tabs) while the + // same PTY stays live under a new leaf. + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Shell', + activeLeafId: 'leaf-new', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-new', + paneRuntimeId: 2, + ptyId: 'pty-plain' + } + ] + }) + + // The waiter must fail fast, not hang until timeout on a dead leaf. + await expect(waiting).rejects.toThrow('terminal_handle_stale') + const after = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(after.terminals).toHaveLength(1) + expect(after.terminals[0].handle).not.toBe(staleHandle) + }) + + it('keeps a live CLI waiter pending when a re-keyed shared handle transfers to the live leaf', async () => { + const runtime = createRuntime() + const tabId = 'tab-1' + // The counterpart to the leaf-unique case above: a pre-allocated + // ORCA_TERMINAL_HANDLE is ptyId-keyed and shared, so re-keying its live PTY to + // a new leaf (a pane moved across tabs, no renderer reload) must hand the + // handle to the live leaf WITHOUT rejecting the agent's in-flight waiter. This + // pins the shared-handle branch so collapsing it into a blanket + // invalidateLeafHandle can't silently strand a CLI `terminal wait`. (A renderer + // reload is a separate path — markRendererReloading stales every waiter itself.) + runtime.preAllocateHandleForPty('pty-agent') + runtime.attachWindow(TEST_WINDOW_ID) + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'leaf-old', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-old', + paneRuntimeId: 1, + ptyId: 'pty-agent' + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + const sharedHandle = before.terminals[0].handle + const abort = new AbortController() + let settled: 'resolved' | 'rejected' | null = null + const waiting = runtime + .waitForTerminal(sharedHandle, { + condition: 'exit', + timeoutMs: 30_000, + signal: abort.signal + }) + .then( + () => { + settled = 'resolved' + }, + () => { + settled = 'rejected' + } + ) + + // Re-key WITHOUT a renderer reload while the same agent PTY stays live under a + // new leaf. + runtime.syncWindowGraph(TEST_WINDOW_ID, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: 'leaf-new', + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: 'leaf-new', + paneRuntimeId: 2, + ptyId: 'pty-agent' + } + ] + }) + + // Let any synchronous stale-handle rejection propagate. + await new Promise((resolve) => setImmediate(resolve)) + // The shared handle belongs to leaf-new now, so the CLI's live waiter must stay + // pending — a blanket invalidateLeafHandle would reject it here as stale. + expect(settled).toBeNull() + // The same handle still resolves to the live PTY under the new leaf. + await expect(runtime.showTerminal(sharedHandle)).resolves.toMatchObject({ + ptyId: 'pty-agent' + }) + + // Abort only for teardown; the assertion above already proved it was pending. + abort.abort() + await waiting + expect(settled).toBe('rejected') + }) + + it('emits one mobile session terminal tab per live PTY even if two tabs resolve to it', () => { + const runtime = createRuntime() + const internals = runtime as unknown as { + recordPtyWorktree: ( + ptyId: string, + worktreeId: string, + state?: Record + ) => void + mobileSessionTabsByWorktree: Map + getMobileSessionTabsForWorktree: (worktreeId: string) => { + tabs: { type: string; terminal?: string | null }[] + } + } + // Two unclaimed live PTYs on the worktree; the worktree-only fallback binds + // either to a leafless tab that references it. + internals.recordPtyWorktree('pty-shared', TEST_WORKTREE_ID, { connected: true }) + internals.recordPtyWorktree('pty-other', TEST_WORKTREE_ID, { connected: true }) + + const terminalTab = (leafId: string, ptyId: string) => ({ + type: 'terminal' as const, + id: `term_dup::${leafId}`, + parentTabId: 'term_dup', + leafId, + ptyId, + title: 'Agent', + isActive: leafId === 'leaf-new' + }) + + // Two records (a stale headless leaf + the live leaf) that both resolve to + // the SAME live PTY — the renderer-graph origin the leaf fix can't reach. + internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer:test:1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'term_dup::leaf-new', + activeTabType: 'terminal', + tabs: [terminalTab('leaf-new', 'pty-shared'), terminalTab('leaf-old', 'pty-shared')] + }) + const deduped = internals.getMobileSessionTabsForWorktree(TEST_WORKTREE_ID) + const dedupedTerminals = deduped.tabs.filter((tab) => tab.type === 'terminal') + expect(dedupedTerminals).toHaveLength(1) + expect(dedupedTerminals[0].terminal).toBeTruthy() + + // Split siblings own DISTINCT PTYs, so they must never be collapsed. + internals.mobileSessionTabsByWorktree.set(TEST_WORKTREE_ID, { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer:test:2', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: 'term_dup::leaf-new', + activeTabType: 'terminal', + tabs: [terminalTab('leaf-new', 'pty-shared'), terminalTab('leaf-old', 'pty-other')] + }) + const split = internals.getMobileSessionTabsForWorktree(TEST_WORKTREE_ID) + expect(split.tabs.filter((tab) => tab.type === 'terminal')).toHaveLength(2) + }) + it('keeps targeted terminal lists from adopting controller PTYs for other worktrees', async () => { vi.mocked(listWorktrees).mockResolvedValue([ ...MOCK_GIT_WORKTREES, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 325c72c362e..b59afc24a32 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2984,26 +2984,47 @@ export class OrcaRuntimeService { } } + // Why: computed BEFORE preserving stale leaves so preservation can refuse a + // leaf whose PTY the incoming graph already rebound to a live leaf. Two + // leaves on one PTY resolve to the same handle (handles are ptyId-keyed) and + // crash paired clients with a duplicate React key. + const nextPtyIds = new Set( + [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) + ) for (const oldLeafKey of this.leaves.keys()) { if (!nextLeaves.has(oldLeafKey)) { const oldLeaf = this.leaves.get(oldLeafKey) if ( preserveLivePtysDuringReload && oldLeaf?.ptyId && - this.handleByPtyId.has(oldLeaf.ptyId) + this.handleByPtyId.has(oldLeaf.ptyId) && + !nextPtyIds.has(oldLeaf.ptyId) ) { // Why: a CLI-created agent keeps using its exported handle even if // the reloaded renderer has not rebound the pane yet. nextLeaves.set(oldLeafKey, oldLeaf) + nextPtyIds.add(oldLeaf.ptyId) + } else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) { + // Why: the incoming graph already rebound this PTY to a live leaf (e.g. + // a woken agent re-keyed to a new leaf during renderer reload). Keeping + // the old leaf too would put two leaves on ONE PTY, which emit the same + // terminal handle and crash paired clients. Drop the stale leaf; if its + // handle is the shared ptyId-keyed one it belongs to the live leaf now, + // so release only this dead leaf key's alias. A leaf-unique handle has + // no next owner — invalidate it so in-flight CLI waiters fail fast + // instead of hanging on a dead leaf. + const oldHandle = this.handleByLeafKey.get(oldLeafKey) + if (oldHandle !== undefined && oldHandle === this.handleByPtyId.get(oldLeaf.ptyId)) { + this.handleByLeafKey.delete(oldLeafKey) + } else { + this.invalidateLeafHandle(oldLeafKey) + } } else { this.invalidateLeafHandle(oldLeafKey) } } } - const nextPtyIds = new Set( - [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) - ) for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) @@ -19286,6 +19307,15 @@ export class OrcaRuntimeService { ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) + // Why: a live PTY backs exactly one terminal surface, so it must map to a + // single emitted tab. After agent sleep + mobile wake, a stale + // headless-hydrated leaf can survive beside the renderer's live leaf and both + // resolve to the freshly-woken agent PTY (same issuePtyHandle handle) — which + // renders two panes with the same React key and crashes the client. Claim + // each live PTY once. Split siblings never collide because distinct leaves own + // distinct PTYs; renderer tabs precede preserved headless tabs, so the live + // one wins. + const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { if (tab.type === 'browser') { const liveTab = tab.browserPageId @@ -19322,6 +19352,19 @@ export class OrcaRuntimeService { allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless') }) const livePty = pty?.connected ? pty : null + // Why: enforce the one-live-PTY-per-tab invariant. A later tab resolving to + // a PTY an earlier tab already claimed is a duplicate surface (e.g. a stale + // headless-hydrated leaf re-bound to a woken agent PTY) — drop it so the + // client never sees two tabs sharing a terminal handle. Handles derive purely + // from the PTY id (issuePtyHandle), so the id is a faithful proxy for the + // emitted handle. Pending tabs (no live PTY) are left untouched. + const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null + if (resolvedLivePtyId !== null) { + if (claimedLivePtyIds.has(resolvedLivePtyId)) { + continue + } + claimedLivePtyIds.add(resolvedLivePtyId) + } const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) From db9fd2f8acb5df2cb9122dd004299d2152804280 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:13:39 -0700 Subject: [PATCH 26/95] chore: remove release-scan artifact and trailing whitespace (#8184) Drop accidental mobile mock HTML and strip trailing spaces from the kill-all-sessions design note without changing prose. --- ...all-sessions-also-kills-empty-terminals.md | 2 +- mobile/mock-source-control-hub.html | 2188 ----------------- 2 files changed, 1 insertion(+), 2189 deletions(-) delete mode 100644 mobile/mock-source-control-hub.html diff --git a/docs/kill-all-sessions-also-kills-empty-terminals.md b/docs/kill-all-sessions-also-kills-empty-terminals.md index 438514cec63..425eb334968 100644 --- a/docs/kill-all-sessions-also-kills-empty-terminals.md +++ b/docs/kill-all-sessions-also-kills-empty-terminals.md @@ -1,6 +1,6 @@ # Kill all sessions also closes terminal tabs -GitHub: https://github.com/stablyai/orca/issues/8001 +GitHub: https://github.com/stablyai/orca/issues/8001 Branch: `bug-kill-all-sessions-doesnt-kill-terminals` ## Problem diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html deleted file mode 100644 index 4e8035adef3..00000000000 --- a/mobile/mock-source-control-hub.html +++ /dev/null @@ -1,2188 +0,0 @@ - - - - - - Orca Mobile – Source Control Hub - - - -
- -
- 9:41 - - - - - - - - - - - - - -
- - -
- -
-

Source Control

-
make-center-split-more-visible
-
- -
- - -
- - - -
- - -
-
- - - - - - - make-center-split-more-visible -
-
- - ↑2 - ↓0 - - 3 changed · 1 staged -
-
-
- - - - - - #7701 - Open - - ✓ 12/12 - 💬 3 - - - - - - -
-
- - -
- -
-
- -
- Staged Changes - 1 - - -
-
-
- ↩ - Unstage -
-
- 🗑 - Discard -
-
- - - - - - M - - - - - - - - EditorFileTab.tsx - - ‎src/renderer/src/components/tab-bar/ - +12−4 - - - - - - - -
-
- - -
- Changes - 3 - - -
-
-
+Stage
-
🗑Discard
-
- - - M - - - - - CenterSplitDivider.tsx - - ‎src/renderer/src/components/split-view/ - +34−8 - - - - -
-
-
-
+Stage
-
🗑Discard
-
- - - M - - - - - split-view-geometry.ts - - ‎src/renderer/src/lib/ - +6−2 - - - - -
-
-
-
+Stage
-
🗑Discard
-
- - - A - - - - - SplitDividerHandle.test.tsx - - ‎src/renderer/src/components/split-view/__tests__/ - +48−0 - - - - -
-
-
- - -
-
- - -
-
- - -
- - -
-
-
- - -
- 0 selected - - - -
-
- - -
-
-
- -
-
- Open - #7701 - octocat wants to merge into main -
-
Make center split divider more visible
-
- make-center-split-more-visible → main · updated 20m ago -
-
- - -
- 1 check failing - - -
-
- - - - - e2e / playwright (ubuntu-latest) - Failing · 1 test · 2m 14s - - -
- - -
-
- Merge method - Squash and merge - - - - -
-
-
-
Auto-merge when checks pass
-
Merges automatically once required checks are green
-
-
- -
-
- -
1 failing check must pass before merging
-
- - -
- Unresolved comments - · 3 - - -
-
- R - - rauchg · 18m ago - Can we clamp the divider width so it doesn't overlap the gutter on narrow - panes? - CenterSplitDivider.tsx:42 - -
-
- M - - maren · 25m ago - Add a test for the min-width geometry branch. - split-view-geometry.ts:16 - -
- - -
- Reviewers -
-
- O - octocat - Approved -
-
- D - dstndev - Review requested -
- - -
- All checks - · 11 passed · 1 failing - - -
-
- - - build / typecheckPassed · 1m 02s -
-
- - - lint / oxlintPassed · 38s -
-
-
-
- - -
-
-
-
-
-
-
Make center split divider more visible
-
- a1b2c3d · Jinjing · 18m ago -
-
-
-
-
-
-
Add split-view geometry helper
-
- 9f8e7d6 · Jinjing · 42m ago -
-
-
- -
- 2 commits ahead of origin/main - -
- -
-
-
-
Merge pull request #7699 from orca/main
-
- 0f7658d · web-flow · 3h ago -
-
-
-
-
-
-
Update README downloads badge
-
2dad1f0 · Jinjing · 5h ago
-
-
-
-
-
-
- - -
-
-
-
-
-
-
- - -
-
- - - - From e1de59aaa8a4b78331374068d98e5fc7cd203595 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:22:47 -0700 Subject: [PATCH 27/95] fix(quick-open): keep primary results when the git ignored pass fails (#8049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-ripgrep git fallback ran both ls-files passes under Promise.all with killSurvivors on first rejection, so any ignored-pass failure (timeout, kill, non-zero exit) discarded the successful primary listing and Quick Open showed an error with zero files — the all-or-nothing failure called out in #7719. Directory collapse (#7842) bounds the enumeration so timeouts are far less likely, but a single-pass failure still nuked every result. Now, in the local main process and the SSH relay alike, only a primary-pass failure is fatal: an ignored-pass failure logs a warning and the listing resolves with the primary results plus any ignored entries streamed before the failure. Cancellation semantics are unchanged — an aborted scan still rejects via the primary pass or the expansion's cancellation check. Fixes #7719 Co-authored-by: Brennan Benson --- .../ipc/filesystem-list-files-git-fallback.ts | 14 +++- src/main/ipc/filesystem-list-files.test.ts | 54 ++++++++++++++ src/relay/fs-handler-git-fallback.ts | 17 ++++- .../fs-handler-list-files-ignored.test.ts | 72 +++++++++++++++++-- 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index 8b5bb526f8e..616a9a10c93 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -216,7 +216,19 @@ export async function listFilesWithGit( const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) try { - await Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) + await Promise.all([ + runGitLsFiles(primary), + // Why: ignored files are supplementary — a failed or timed-out ignored + // pass must not discard the primary listing the user actually needs + // (#7719 root cause: the all-or-nothing failure showed zero files). + // Entries streamed before the failure are kept; a cancelled scan still + // rejects via the primary pass or the expansion's cancellation check. + runGitLsFiles(ignoredPass).catch((err: Error) => { + if (!signal?.aborted) { + console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) + } + }) + ]) } catch (err) { killSurvivors() if (signal?.aborted) { diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index a3a556b2340..ba535a25868 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -475,6 +475,60 @@ describe('filesystem-list-files', () => { } }) + it('keeps primary results when only the ignored pass times out', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + + try { + const revParseProc = createMockProcess() + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { + callIndex++ + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('/mock/root', storeMock) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + revParseProc.emit('close', 0, null) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + ;(gitP1.stdout as unknown as EventEmitter).emit( + 'data', + `${staged('100644', 'src/index.ts')}\0` + ) + gitP1.emit('close', 0, null) + // Ignored entries streamed before the timeout are kept. + ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + + await vi.advanceTimersByTimeAsync(10000) + + await expect(promise).resolves.toEqual( + expect.arrayContaining(['src/index.ts', 'dist/generated.js']) + ) + expect(gitP2.kill).toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + } finally { + vi.useRealTimers() + warnSpy.mockRestore() + } + }) + it('does not fall back to git when rg is available', async () => { checkRgAvailableMock.mockResolvedValue(true) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 6cceb5de7a7..ae418f9e881 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -176,7 +176,22 @@ export function listFilesWithGit( const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) - return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) + return Promise.all([ + runGitLsFiles(primary), + // Why: ignored files are supplementary — a failed or timed-out ignored + // pass must not discard the primary listing the user actually needs + // (#7719 root cause: the all-or-nothing failure showed zero files). + // Entries streamed before the failure are kept; a cancelled scan still + // rejects via the primary pass or the expansion's cancellation check. + runGitLsFiles(ignoredPass).catch((err: Error) => { + if (!signal?.aborted) { + console.warn( + '[relay quick-open] git ignored-file pass failed; keeping primary results:', + err + ) + } + }) + ]) .then(async () => { const files = await expandQuickOpenGitFileListing({ rootPath, diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 5a1804437bc..d76fe40b517 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -182,7 +182,65 @@ describe('relay quick open ignored file listing', () => { ]) }) - it('git fallback rejects signal exits instead of returning partial results', async () => { + it('git fallback keeps primary results when the ignored pass is killed', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root') + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.emit('close', 0, null) + + // Entries streamed before the kill are kept alongside the primary pass. + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', null, 'SIGTERM') + }, 10) + + await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts']) + expect(warnSpy).toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('git fallback keeps primary results when the ignored pass exits non-zero', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation(() => { + callIndex++ + return callIndex === 1 ? primaryProc : ignoredProc + }) + + const promise = listFilesWithGit('/remote/root') + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.emit('close', 0, null) + + ignoredProc.emit('close', 128, null) + }, 10) + + await expect(promise).resolves.toEqual(['src/index.ts']) + expect(warnSpy).toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('git fallback rejects when the primary pass is killed', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() let callIndex = 0 @@ -196,16 +254,16 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') - primaryProc.emit('close', 0, null) + primaryProc.emit('close', null, 'SIGTERM') ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', null, 'SIGTERM') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') }) - it('git fallback rejects non-zero exits instead of expanding a partial result set', async () => { + it('git fallback rejects when the primary pass exits non-zero', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() let callIndex = 0 @@ -218,10 +276,10 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') - primaryProc.emit('close', 0, null) + primaryProc.emit('close', 128, null) - ignoredProc.emit('close', 128, null) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files exited with code 128') From e84a8ddec97d5f367298fa22a9db2bbed8478af4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:27:47 -0700 Subject: [PATCH 28/95] Terminal performance initiative: pipeline fixes + term-speed-2 revival + PTY flow control (integration branch) (#7214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip legacy hidden skip grammar assertions * Fix hidden TUI snapshot test setup * Fix sleep wake history test contract * Fix hidden delivery startup gate helper * Fix hidden Latin skip branch predicate * Fix hidden synchronized split-boundary replay * Stabilize remote runtime mixed subscription test * Keep hidden startup query parser active during window * Stabilize raw emoji golden restore width * Stabilize raw emoji golden fixture completion * Keep terminals responsive under agent output load * Add frozen-terminal repro harness and silent-drop regression tests Investigation harness for the frozen-terminal reports (Discord #performance, issue #2836): pane shows content, shell alive, daemon output.log flat while typing. - e2e: renderer crash -> auto-reload recovery and three restart/restore shapes (live daemon, SIGSTOP-wedged daemon, daemon killed between launches), each probing input at both drop layers. Post-crash phases drive the renderer from the main process because a crashed target severs Playwright's CDP session even though the app recovers. - e2e helpers: layer-discriminating probes (direct pty.write vs transport input, plus pty:listSessions ownership-rebuild revival). - unit repro: vendored xterm 6.1.0-beta.287 WriteBuffer permanently wedges when a sync throw escapes a write-completion callback or a custom parser handler (xterm-write-buffer-stall.repro.test.ts). - unit repros for both silent input-drop layers: main drops writes for a live PTY once ptyOwnership loses the id (revived by listSessions), and the renderer transport stays unbound after a failed connect. - pty.test.ts: unregister every leaked SSH provider id in afterEach so module-level provider state cannot leak across tests. Co-authored-by: Orca * Harden xterm write pipeline against sync-throw wedge that freezes panes A synchronous exception escaping xterm's WriteBuffer loop permanently wedges that terminal: _innerWrite has no try/catch around the parse action or the write-completion callback, the tail re-schedule never runs, and write() only re-arms on an empty buffer. The pane stops rendering and, if a replay was in flight, the replay guard latches and pty-connection's onData silently eats every keystroke — matching the field reports (Discord #performance, issue #2836: content visible, shell alive, daemon output.log flat). Both vectors verified against vendored xterm 6.1.0-beta.287 in xterm-write-buffer-stall.repro.test.ts. Three layers of defense: - Guard every write-completion callback Orca hands xterm at the two choke points (writeForegroundTerminalChunk, writeBackgroundTerminalChunk), with settle and onParsed guarded separately so a WebGL/renderer failure during viewport settle cannot starve the replay-guard release. - Guard all throwing-capable custom parser handlers (DA1, OSC 10/11, CSI ?h/?l mode reports, OSC 52 clipboard, OSC 7 cwd), degrading a throw to "not handled" — same escape class as terminal-link-provider-guard.ts. - Replay-guard watchdog: each engagement releases exactly once, from xterm's completion or a 10s watchdog, so a lost completion (wedged pipeline, disposed-terminal race) cannot latch the guard on a live pane; replayIntoTerminalAsync resolves on either path so restore chains cannot hang. Force-releases record a crash breadcrumb. All guard trips record rate-capped crash breadcrumbs, so the next field occurrence names the throwing stack instead of failing silently. Co-authored-by: Orca * Cap unbounded terminal output buffers in main and the foreground queue Field evidence (Discord #performance / #2836): renderer memory climbs to ~1.5 GB and terminals freeze; a force reload does not help until memory recovers. Two unbounded buffers matched that shape: - Main-process pendingData grew by string concatenation without bound while the renderer could not receive (frozen, starved, mid-reload) — main-heap bloat a renderer reload cannot clear. Now capped at 2 MB per PTY: past the cap the buffered bytes are dropped and the entry stays O(1) until the renderer ACKs again, then a droppedOutput sentinel is delivered and the pane repaints from the authoritative main-owned buffer snapshot (existing hidden-output restore path) instead of continuing a stream with a silent gap. - The renderer output scheduler capped only hidden-pane backlogs; the foreground path could queue a visible pane's flood without bound when the drain could not keep up. The 2 MB cap now applies to every foreground enqueue branch too, with a foreground-specific skip notice. Verified: new main-side cap test (starve → flood → sentinel → normal flow resumes), renderer sentinel-to-snapshot-restore test, two foreground scheduler cap tests; full pty/terminal-pane/pane-manager suites (1981 tests) and typecheck pass. Co-authored-by: Orca * Make replay-guard stall release probe-certified instead of time-based The previous stall watchdog blindly released the input guard after 10s. If a replay were genuinely still parsing on a starved machine, that early release could leak xterm's auto-replies into the shell — and into agent TUIs, where a leaked ESC reads as the user pressing Escape. Replace the blind release with a probe: when a completion looks overdue, enqueue an empty write behind the replay. xterm parses writes in order, so every outcome is provably safe: - probe parses after the replay completion ran: normal release already happened; probe is a no-op. - probe parses but the replay completion never ran: all replay bytes have parsed, no further auto-replies can exist — the completion was genuinely lost. Release + breadcrumb. - probe never parses (bounded wait): the pipeline is wedged, and a dead parser can never emit auto-replies, so releasing cannot leak input. Release + breadcrumb naming the pane as needing recovery. While the probe is pending — a slow-but-alive replay — the guard now HOLDS instead of releasing early; that case is pinned by a regression test. Co-authored-by: Orca * Scale output backlog caps with the scrollback setting and breadcrumb drops The 2 MB pending-output caps were flat, which risked dropping lines a 50k-row scrollback user would have retained. Both caps (main pendingData and the renderer output queue) now derive from one shared policy: max(2 MB, scrollbackRows x 120 chars) — 2 MB at the 5k default, 6 MB at the 50k max. The main side reads the setting live via getSettings; the renderer scheduler is configured where the terminal lifecycle already reads the scrollback setting. Every drop now records a rate-limited crash breadcrumb with dropped and cap sizes (terminal_output_backlog_dropped in the renderer, terminal_pending_output_dropped in main — no pty ids, session ids can embed workspace paths). Field drop frequency and size decide whether the cap constants need raising, replacing theory with data (#2836, #7017). Backlog skip notices are now cap-agnostic since the limit varies. Co-authored-by: Orca * Extract breadcrumb recording into a collection-safe leaf module Playwright loads spec imports at collection time, and e2e specs import terminal-module constants (e.g. terminal-attention.spec.ts pulls POST_REPLAY_MODE_RESET from layout-serialization, whose chain reaches replay-guard). The breadcrumb import added to the terminal modules made that chain reach crash-diagnostics.ts, whose top-level import.meta.hot and webview-registry import crash Playwright's transform ("ReferenceError: exports is not defined in ES module scope") — every e2e shard failed at collection before running a single test. Move recordRendererCrashBreadcrumb into crash-breadcrumb-recorder.ts (type-only imports, no import.meta) and point the terminal modules and their test mocks at it; crash-diagnostics re-exports for existing callers. Full e2e suite collects again (262 tests / 94 files); unit suites, typecheck, lint green. No runtime behavior change. Co-authored-by: Orca * Add cross-terminal pipeline benchmark (DSR-fenced throughput + latency probe) Run inside any terminal (Orca pane, iTerm2, Ghostty, Terminal.app, VS Code) to measure its full byte path. DSR round-trip latency at idle and under a paced agent-TUI load, plus fenced throughput over four deterministic fixtures. The DSR fence forces 'all bytes parsed' before the clock stops so xterm.js-class ingest queues can't flatter the result. First piece of the terminal performance initiative's measurement rig. Co-authored-by: Orca * Add terminal performance initiative plan Working plan for the orca-performance branch: verified architecture findings, workstreams (baselines, #7153 validation, term-speed-2 revival with merge-scout numbers, stall fixes, flow control, rig extensions, utilityProcess router, telemetry), benchmark protocol, sequencing, and baseline-relative success criteria. Co-authored-by: Orca * Add cross-terminal baseline results (Orca 1.4.91 prod vs Terminal.app vs Ghostty) Headline: Orca DSR latency under 1MB/s agent-TUI load is p50 134ms / p99 292ms vs 0.45ms (Terminal.app) and 0.21ms (Ghostty). Idle latency is fine (0.69ms p50) — the problem is queueing under load, not the pipeline hop. agent-tui fenced throughput: Orca 2.0 MB/s vs Terminal.app 37 MB/s, Ghostty 78 MB/s. Co-authored-by: Orca * Add pipeline-loss decomposition benches (headless xterm + daemon ingest) Both isolate layers of the 51x agent-tui gap found in baseline-jul02: bare @xterm/headless parses agent-tui at 103 MB/s and daemon Session ingest (emulator + pending-output recording + fanout) at 103 MB/s — on the byte stream the full Orca pipeline delivers at 2.0 MB/s. Parser and daemon are exonerated; the loss is in main per-chunk processing, delivery/ACK pacing, or renderer layers above xterm. Co-authored-by: Orca * Record baseline + decomposition findings in initiative plan Co-authored-by: Orca * Add dev-build orca-performance bench result (confounded: dev mode, 282-col window, 3MB fixtures) DSR under load p50 161ms — the #7139/#7150 branch does not move the under-load latency class. Expected in hindsight: DSR replies are ordered within the output stream, so the metric measures output-queue depth; cooperative drain paces input responsiveness but cannot reorder the queue. Shrinking the queue itself (producer flow control, task 6) and raising agent-tui throughput (task 9) are the levers for this number. Co-authored-by: Orca * Record dev-build #7153 check in findings log Co-authored-by: Orca * Parse-clock high-priority terminal drains instead of fixed-nap dripping Attribution (task #9): the drain loop wrote at most 2x16KB then slept 4/16ms regardless of parse speed — an isolation bench (new pane-terminal-output-scheduler-throughput.bench.test.ts) measures that drip at 1.9 MB/s background / 27 MB/s foreground against xterm's ~103 MB/s parse rate, matching the baseline-jul02 end-to-end numbers (agent-tui 2.0 MB/s in prod 1.4.91). Fix: high-priority (visible-pane) drains now re-arm on xterm's parse-completion callback and carry 8 writes per tick; the isolation ceiling rises 27 -> 117.6 MB/s (parse-limited). Background cadence is deliberately unchanged (2 MB/s drip protects the focused pane; hidden delivery is term-speed-2's job). DRAIN_TIME_BUDGET_MS still bounds per-tick work, preserving #7139's cooperative-drain intent. Validation: 621 scheduler/guard/pty tests green, typecheck clean. Co-authored-by: Orca * Record task #9 attribution + parse-clock fix in findings log Co-authored-by: Orca * Findings: 51x loss attributed to O(tail) retained-tail redraw path in main onPtyData Co-authored-by: Orca * Window the retained-tail redraw path to the cursor's reach Attribution (findings log 2026-07-03): main's onPtyData consumed ~93% of the event loop under an agent-TUI flood, and the dominant term was appendNormalizedToMultilineTailBuffer + finalizeRetainedTerminalRows materializing ~2x tail-length row objects plus a per-row trailing-space regex on every chunk — 0.888ms/chunk at the 2,000-line cap, on every Claude-Code-shaped frame (cursor-up + erase-below). The multiline algorithm now runs on a suffix window sized by the chunk's maximum upward cursor excursion (plus the inherited redraw cursor and a safety margin); the untouched prefix is shared by reference with a cheap last-char trailing-space check to match the reference trim. Pathological full-height cursor-ups fall back to the unwindowed implementation, which is kept verbatim and exported as the reference for the 500-case differential fuzz (retained-tail-redraw-window.equivalence.test.ts). Micro-bench at a full 2,000-line tail: 0.888 -> 0.073 ms/chunk (12x). 1,415 runtime tests green, typecheck clean. Co-authored-by: Orca * Add dev bench results: parse-clock and windowed-tail fixes Co-authored-by: Orca * Record windowed-tail partial win + next-cycle recipe in findings log Co-authored-by: Orca * Findings: remaining whale is the per-chunk blocked-reason check (~85% of onPtyData post-fix) Co-authored-by: Orca * Throttle the terminal wait-blocked check off the PTY hot path Post-windowed-tail attribution (findings log 2026-07-03): the blocked- reason complex — two full-tail buildTerminalWaitText builds plus toLowerCase and multi-pattern scans per chunk, existing only to stamp waitBlockedAt — consumed ~85% of onPtyData's remaining cost (~700-790ms/s under an agent-TUI flood). The check now runs at a 50ms cadence over coalesced chunks (PTY chunk boundaries are arbitrary, so coalescing preserves semantics), with a trailing-edge timer so burst-final state is always evaluated, and an immediate bypass when the incoming chunk (plus a 31-char split carry) contains a prompt keyword — so actionable-prompt stamping stays per-chunk-immediate while keyword-free flood frames skip the complex entirely. Previous wait text is cached per pty instead of rebuilt, and state is cleared at both pty teardown sites. 1,415 runtime tests green (including the cross-chunk prompt test, which exercises the keyword bypass), typecheck and lint clean. Co-authored-by: Orca * Findings + results: three stacked fixes unlock the pipeline (agent-tui 16x, DSR-load p50 161->18.8ms in dev) Co-authored-by: Orca * Add producer flow-control design to initiative plan Co-authored-by: Orca * Findings: revival branch green but perf-gated — daemon Session ingest regressed 103->40-48 MB/s (chain emulator restructure); merge blocked until blockedfix parity Co-authored-by: Orca * Pre-filter daemon OSC/mouse scanners for introducer-free chunks Skips the scan-tail copy and full-chunk walks when a chunk cannot contain an OSC or private-mode sequence (single native includes() checks), with split-sequence correctness preserved via explicit tail retention. Strictly positive micro-optimization on the daemon per-chunk path; 641 daemon tests green (1 pre-existing WSL failure unrelated). Co-authored-by: Orca * Retract confounded daemon conviction; mandate load-controlled A/B protocol for the revival merge gate Co-authored-by: Orca * Record A/B gate pass in findings log; add A/B result JSONs Co-authored-by: Orca * Add producer-side PTY flow control (watermarks + protocol v19) Main now pauses the actual PTY when a pane's renderer-pending backlog crosses the 256KB high watermark and resumes once it drains below the 32KB low watermark (wide hysteresis band so a draining queue cannot flap pause/resume per flush slice). node-pty pause() stops the pty fd read, so the kernel/ConPTY buffer fills and a flooding shell blocks on write — flood-induced buffered lag becomes shell blocking instead of unbounded main-process buffering (terminal-performance-initiative §5). Transport: new fire-and-forget pausePty/resumePty daemon notifications (protocol v19; 18 added to PREVIOUS_DAEMON_PROTOCOL_VERSIONS), routed DaemonServer -> TerminalHost -> Session -> subprocess pause()/resume(). LocalPtyProvider pauses node-pty directly. Router/degraded providers forward; IPtyProvider gains optional pauseProducer/resumeProducer. Safety invariants: - Lost-resume failsafe: daemon Session auto-resumes 5s after a pause with no matching resume; main re-asserts the pause at most once per 5s while still above the high watermark, so a lost resume can never wedge a shell and a sustained flood stays throttled. - Resume on every teardown path: Session kill/exit/dispose/detach; main releases on pty exit and on window-destroyed bookkeeping wipes; the adapter owes paused sessions a resumePty on the next connect after a socket drop. - Providers without support (SSH relay, legacy protocol <= v18) no-op silently, and the scrollback-scaled pending-output cap still bounds main memory when pause is unavailable. - Kill switch: PRODUCER_FLOW_CONTROL_ENABLED in ipc/pty.ts flips the whole mechanism off in one line. daemon-errors.ts is split out of types.ts to stay under the max-lines cap. Tests: watermark transitions/hysteresis/re-assert (controller unit), lost-resume failsafe + resume-on-kill/exit/dispose/detach (session), notification routing + v18 gating + reconnect owed-resume (adapter), direct pause/resume (local provider), and a flood test asserting pause fires once, pending stays bounded at HIGH + one chunk, and resume fires once after drain (ipc/pty). Co-authored-by: Orca * Findings: flow control merged; definition-of-done accounting; prod verification re-scoped to packaged RC Co-authored-by: Orca * Fix stray brace from revival merge in long-table-scroll-restore e2e spec (broke e2e transform in CI) Co-authored-by: Orca * Prod verdict: v1.4.121-rc.0 bench — DSR-load p50 134->18.6ms (7.2x), agent-tui 2.0->11.2 MB/s, idle at Terminal.app parity; pipeline now cadence-bound Co-authored-by: Orca * Recover terminal output delivery after system sleep Root cause: main gates every pty:data send on a global + per-PTY in-flight counter that only renderer ACKs decrement. If ACKs are lost across a system suspend, the counters pin at the cap and every PTY — old and newly created — is silently gated forever while output piles up in pendingData. A focus-preserving display wake also fires no renderer focus/visibilitychange events, so terminal wake recovery (and the WebGL context-loss latch clear) never runs. Only a renderer reload recovered. Three fixes: - ACK-stall watchdog (src/main/ipc/pty.ts): if sends stay gate-blocked for 10s with zero ACK progress while the renderer webContents is alive, warn once, reset the in-flight delivery counters, and flush held pendingData. Armed lazily on the first gate-blocked send and disarmed by every ACK, so it can never fire under healthy heavy load. - Renderer lifecycle reset now also zeroes the in-flight counters — a reload/navigation destroys the renderer dispatcher, so outstanding ACKs can never arrive and stale counters would gate the new renderer. - System-resume wake IPC: main relays powerMonitor 'resume' as system:resumed to live windows (plus forceRepaint); preload exposes ui.onSystemResumed; the terminal wake-recovery hook runs the same recovery path as window focus/visibilitychange. Co-authored-by: Orca * VS Code head-to-head: Orca beats/ties 5 of 6 metrics (16x idle, 5x styles-stress, better p99); load p50 gap attributed to ACK window + timer-clamped drain cadence Co-authored-by: Orca * Schedule zero-delay terminal drains via MessageChannel Chromium clamps nested setTimeout(0) to ~4ms, stacking dead gaps onto every parse-clocked drain tick; the explicit 4ms high-priority re-arm interval added more. A posted message is still a macrotask — input and paint are serviced between posts — so cooperative yielding survives without the clamp. Generation-tokened cancellation; vitest keeps the timer path (fake timers can't advance channel posts) plus a real-timer smoke test for the channel path. Standing-queue target: VS Code's ~7ms class (measured us 18.6ms, them 7.18ms, same rig). Co-authored-by: Orca * Cut daemon and main PTY batch windows 8ms -> 2ms At 9% pipeline utilization the DSR-under-load latency is fixed batching windows, not queue depth (proved by the MessageChannel drain lever moving nothing). Both hops charged an expected half-window per chunk; 2ms keeps burst coalescing at negligible IPC overhead (~500 msgs/s worst case vs MB/s payloads). Co-authored-by: Orca * Findings + tests: batch windows were the DSR-load gap (19->8.0ms dev); timing tests updated to 2ms windows Co-authored-by: Orca * Fix PR CI and guard resume relay during shutdown Co-authored-by: Orca * Chain e2e specs 6/6 green — gate x drain validation debt paid Co-authored-by: Orca * Replace ack-stall watchdog with cumulative ACKs + solicited delivery resync Design review: the 10s blind-reset watchdog decided correctness from a wall-clock threshold. Rework piece 1 into a deterministic two-part design (pieces 2 and 3 — lifecycle-reset counter zeroing and powerMonitor wake IPC — are unchanged): - Cumulative ACKs (TCP-style): the renderer dispatcher now tracks a monotonic per-pty total of processed chars (terminal-pty-ack-gate) and sends it on every ACK alongside the legacy per-chunk delta. Main keeps per-pty sentChars/ackedChars and max-merges received totals — idempotent and reorder-tolerant, so a lost ACK self-heals when any later ACK arrives instead of becoming permanent in-flight debt. Provider (SSH/daemon) backpressure is credited only the derived delta, clamped, never negative. Main tolerates both payload shapes keyed by field presence (dev hot-reload can mix renderer/main versions); totals reset on pty exit and renderer lifecycle reset on both sides. - Solicited resync (replaces the blind reset): when new pty data arrives while that pty's delivery is fully gated and no probe is outstanding, main sends pty:requestDeliveryResync; the renderer replies with its cumulative totals and main reconciles via max-merge, then flushes held pendingData. Event-triggered, verified-state recovery — no wall-clock threshold decides correctness. The only timer is a 5s request/response hygiene timeout that clears the outstanding flag and logs one diagnostic warn per silent streak; it never mutates counters (a renderer that cannot answer has dead IPC — reload is the only cure). The 10s corrective watchdog is deleted. Co-authored-by: Orca * Starting point: prior agent's garble differential fuzz harness Three files recovered (were untracked) from a prior agent killed by API outages, plus a trivial curly-brace lint fix in the op dispatcher so the pre-commit hook passes: - src/shared/agent-tui-ansi-fuzz-stream.ts (seeded agent-TUI byte-stream gen) - src/shared/terminal-restore-parity-fixture.ts (renderer-parity fixture) - src/main/daemon/headless-emulator-fidelity.fuzz.test.ts (suite 1: differential HeadlessEmulator vs @xterm/headless reference on identical bytes) Co-authored-by: Orca * Suite 1 findings: two new serialize round-trip bugs (B bold-loss, C cursor) Scanned seeds 1..2000. Beyond the pre-documented serialize wrap-null-cell bug (A, 27 seeds, tolerated), the fuzz surfaced two NEW real @xterm/addon-serialize 0.15.0-beta.287 round-trip defects, both of which garble a revealed hidden pane: - Bug B (seeds 435, 770, 1321): serializing a dim cell followed by a bold-only cell emits \x1b[1;22m; SGR 22 clears bold too, so restored bold is lost. Minimal repro: '\x1b[2mA\x1b[22m\x1b[1mB' -> restored 'B' loses bold. - Bug C (seeds 454, 1696): a final content row filled to the right margin leaves xterm wrap-pending; the serializer's relative cursor restore lands one column short. Minimal repro: '0123456789\x1b[3;5H' at cols=10 -> cursor x=3 not x=4. Both isolated to pure serializer replay (no Orca preamble), confirming upstream. Parity fixture verified faithful to the renderer pane's buffer options. Each is pinned as a standalone it.skip repro; full evidence + classification in notes/garble-fuzz-divergences.md. Seed 113 (handoff's DECSC/DECRC case) does not diverge on the current harness. No production code changed. Co-authored-by: Orca * Add perf prerelease update check modifier Co-authored-by: Orca * Suite 2: hidden-reveal seq-reconciliation fuzz + two new snapshot bugs (D, E) Property-tests the reveal seq-reconciliation byte-stitch (getChunkDataAfterSnapshot / reconcileChunkAgainstRestoredSnapshot in pty-connection.ts), mirrored exactly: N=200 seeded hide/reveal scenarios with a rich agent-TUI hidden prefix snapshot and an append-only racing tail, chunked with seq/rawLength meta, seq-domain restarts, unmetered chunks and droppedOutput markers. Asserts snapshot-at-S + reconciled tail == snapshot-of-everything (seq-neutral) and == always-visible (end-to-end). Runtime ~5s at 200; FUZZ_ITERATIONS override documented. Two NEW real snapshot-limitation garbles found while building it, both distinct from suite 1's serialize bugs and pinned as standalone it.skip repros: - Bug D: the DECSC saved-cursor register is not serialized. A hidden TUI that saves the cursor (ESC 7 / CSI s) and restores it on reveal (ESC 8 / CSI u) lands the restore at home. Repro: 'AB\x1b7\x1b[4;10HCD' + '\x1b8X' -> 'XB' vs 'ABX'. - Bug E: a snapshot taken mid-escape-sequence (a PTY read split an escape) drops the partial sequence (it's parser state, not buffer), so the tail's continuation renders literal. Repro: 'AB\x1b[3' + 'mCD' -> 'ABmCD' vs 'ABCD'. Fired on ~24% of the corpus (tolerated + counted via prefixEndsMidSequence). The append-only-tail design isolates seq reconciliation from these and the Bug C cursor cascade. Full evidence + fix directions in notes. No production changes. Co-authored-by: Orca * Suite 3: 25-cycle park/reveal drift e2e test Extends terminal-hidden-view-parking.spec.ts with a deterministic 25-cycle park->reveal test on a static rich alt-screen TUI frame (box drawing, SGR colors, wide CJK/emoji). Baselines against the frame after the first snapshot restore (so both sides pass through identical machinery — the alt-screen restore correctly drops normal-buffer scrollback, which is contract not garble), then asserts every subsequent reveal reproduces it byte-for-byte with no accumulated drift and no hidden-skip banner. Exercises the real renderer teardown + HeadlessEmulator snapshot restore + PTY reattach path the fuzz suites model in isolation. Passes in ~29s (electron-headless, workers=1). Co-authored-by: Orca * Fix two serialize round-trip bugs garbling hidden-terminal snapshot restore BUG B (addon patch): @xterm/addon-serialize's SGR diff emitted bold/dim set params before the shared intensity reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a still-set bold/dim. Patched via pnpm patchedDependencies (config/patches) to diff bold+dim as one intensity group with the clearing 22 emitted first. Other flag pairs (4/24, 3/23, 7/27, ...) have dedicated resets and were verified unaffected. BUG C (Orca-side hardening): the addon restores the cursor with relative moves computed from where it assumes replay leaves the cursor; a final row filled exactly to the right margin leaves replay wrap-pending and the restore lands one column short. New shared serializeWithAbsoluteCursor appends an absolute CUP from the source terminal's authoritative cursor at every restore/replay serialize site (daemon/runtime HeadlessEmulator.getSnapshot, renderer mobile snapshot serializer, shutdown layout capture). It skips empty snapshots and wrap-pending sources so it never changes already-correct behavior. Round-trip repros + non-regression coverage in src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts (verified failing with the fixes stashed). buildRehydrateSequences extracted to its own module to keep headless-emulator.ts under the max-lines budget. Co-authored-by: Orca * Gates: tolerate+count Bugs B/C in deep mode; drop inverse from reconciliation tail - Fidelity suite: add snapshotHasSelfCancellingBoldReset (Bug B) and isMarginWrapPendingCursorOffByOne (Bug C) predicates so the corpus tolerates + counts them like Bug A. FUZZ_ITERATIONS=2000 is now green (~113s) and fails only on genuinely new divergences; each tolerance keeps its <50% degeneracy guard. Default 300 unchanged (~17s). - Reconciliation suite: drop SGR 7 (inverse) from the append-only tail. Inverse marks trailing blanks with an inverse-fg the serializer round-trips slightly differently by capture depth — a Bug-B-class serialize nuance, not seq reconciliation. FUZZ_ITERATIONS=1000 is now green; default 200 unchanged. - Notes updated: every bug class is both pinned (skipped repro) and tolerated in its corpus; combined default runtime ~19s. Regex uses String.fromCharCode(27) to stay oxlint no-control-regex clean. Co-authored-by: Orca * Keep RC update checks off perf prereleases Co-authored-by: Orca * Fix snapshot DECSC register loss (Bug D) and mid-escape boundary drop (Bug E) Bug D: the serialized screen cannot carry the VT100 DECSC saved-cursor register, so a hidden ESC 7 followed by a post-reveal ESC 8 restored to home and clobbered live cells. The snapshot epilogue now re-saves at the source's saved position before the final absolute CUP (readSavedCursorRegister + serializeWithAbsoluteCursor; the active buffer's own register, so alt screens carry theirs). Position-only by design; never-saved terminals are left untouched. Bug E: a PTY read ending mid-escape leaves the sequence in the emulator's parser, so serialize dropped it and the racing tail's continuation bytes rendered literally after reveal (~24% of the fuzz corpus). The emulator now tracks the unparsed trailing partial at ingest (terminal-partial-escape-tail.ts, committed post-parse like the mouse mirror) and ships it as TerminalSnapshot.pendingEscapeTailAnsi. applyMainBufferSnapshot writes it LAST, after POST_REPLAY reset — any later ESC would abort the dangling sequence. Seq accounting is unchanged: the tail is a suffix of bytes the snapshot seq already counts, so reconcile slicing needs no adjustment. Fuzz suites: unskip the Bug B/C repros (fixed on this branch) and the new D/E repros; remove the B/C/E tolerance predicates so regressions fail loudly. Only Bug A (upstream wrap null-cell) stays tolerated + counted. Green at FUZZ_ITERATIONS=2000 (fidelity) and 1000 (reconciliation). Co-authored-by: Orca * Count suffixed RC tags (rc.N.perf) in the shared rc counter — second suffixed cut collided with the first Co-authored-by: Orca * Classify suffixed rc tags (rc.N.perf) as rc telemetry identity in release builds The build-identity guard only knew vX.Y.Z and vX.Y.Z-rc.N, so suffixed perf RCs cut fine but every platform build refused the tag and the releases published empty. Co-authored-by: Orca * Cut the hidden-restore flood feedback loop (A) + query carve-out on drops (B) (A) Under a foreground flood, the hidden-output-restore loop re-fetched snapshots endlessly: each synchronous applyMainBufferSnapshot starved ACK processing, main pinned at the in-flight cap, dropped at the pending cap, and every droppedOutput/modelRestoreNeeded marker re-armed another restore until the flood ended (rc.7.perf DSR timeouts). - Restore loop: a foreground live-chunk queue overflow now abandons the restore immediately (the stream is outrunning snapshot fetch+replay), with a 3-iteration hard cap + lifecycle warn as backstop. - Re-arm gate: drop markers/sentinels and reconcile seq-gaps on a visible pane during its own in-flight/just-abandoned restore no longer re-arm; live bytes write through and ONE deferred repaint (2s after the last backpressure signal) heals the gap. Hidden-pane gate semantics are unchanged. - Query salvage: discarding queued restore bytes (overflow/refetch) now extracts DSR/CPR/DA/OSC-color queries and replays them to xterm so replies still flow. (B) Main-side: dropOversizedPendingPtyData carves reply-eliciting query sequences out of the dropped buffer (and out of post-drop latched data, bounded) and ships them on the droppedOutput sentinel, so DSR probes survive bulk drops. Query scanning moved to src/shared/terminal-reply-query-extraction.ts, shared verbatim with the renderer's hidden-startup query extraction. Co-authored-by: Orca * ACK terminal output at parse-drain, not dispatcher enqueue (C) The renderer credited main's per-PTY in-flight window the moment a pty:data chunk entered the dispatcher, so the 512KB window meant "bytes received", never "bytes parsed". Under flood the renderer write queue grew unbounded behind instant ACKs; main saw no backpressure, crossed the pending cap, and bulk-dropped output (rc.7.perf DSR timeouts). Crediting is now parse-deferred: each delivery carries a fire-once credit (deliverPtyDataWithDeferredAck); the pane's first scheduler write claims it (writeTerminalOutput.ackCredit) and the output scheduler fires it when the bytes are consumed — after terminal.write in the parse-clocked drain, or on ANY discard path (backlog cap replacement, discardTerminalOutput, disposed-terminal drops, flush recovery). Deliveries that never reach the scheduler (reconcile drops, restore queueing, pre-mount eager buffer) settle at handler return, so the invariant holds: every delivered chunk credits exactly once, parsed or discarded. E2E ack-gate hold/release and delivery-resync semantics are unchanged (all crediting still routes through ackPtyData). Main-side equilibrium: with ACKs at parse cadence, in-flight becomes true backpressure — pendingData stays near the 256KB producer-pause watermark, far under the >=2MB drop cap, so bulk floods block the shell (node-pty pause) instead of dropping. Co-authored-by: Orca * Synthesize salvaged query replies directly instead of replaying into xterm The 10MB dev bench proved the write-back salvage insufficient: a pending-cap drop always triggers a snapshot restore, whose replay guard swallows xterm auto-replies and whose discardTerminalOutput races away still-queued query writes — the salvaged DSR died both ways and the fence still timed out. Salvage now answers directly on the input path (immune to both): CPR (CSI 6n) from the live buffer via transport.sendInput, DA1 with the renderer's canned response, OSC color probes via the existing direct responder. Rare queries (DECRQM, DA2) keep the best-effort xterm replay. Co-authored-by: Orca * Untrack branch-added bench result JSONs (20 files); keep numbers in the findings log Files stay on disk; main's 7 pre-existing results are untouched. Co-authored-by: Orca * Branch guide: document merge-not-rebase sync strategy and conflict pattern Co-authored-by: Orca * Merge origin/main (#7316 tab-strip click-vs-drag fix); adapt #7290 recovery-reload tests to this branch's dual did-finish-load listeners The three tests grabbed the FIRST did-finish-load listener; on this branch the renderer delivery-gate reset registers before the orphan sweep, so the sweep tests exercised the wrong handler (one failing, two vacuously green). They now fire all listeners like a real reload. Co-authored-by: Orca * Fix branch CI lint: split pane-interaction functions out of artificial-opencode-terminal-load.spec (815>800 lines), modernize perf-html-report script No max-lines disable per repo rules; extracted to artificial-opencode-pane-interactions.ts. toReversed() and import.meta.filename replace reverse()/fileURLToPath. Co-authored-by: Orca * Fix Windows update-relaunch killing the live terminal daemon On a Windows update relaunch the daemon can be wedged past every RPC budget (final checkpoint flush + installer/AV disk pressure), so the 3s health check AND the 5s session-list hello both time out while sessions are still alive - and the launcher failed closed, killing the daemon and every terminal session it owned. - Adopt an unresponsive daemon whose pipe still accepts a raw connection; a new rejected health state keeps replacing daemons that answered and refused the handshake (never adoptable). - Give Windows pid files a real startedAtMs (daemon self-reports it in the ready IPC message) and verify it via CIM CreationDate piggybacked on the existing command-line query, so the pid-recycling guard is no longer inert on win32. - Only delete legacy daemon pid/token files when the pid-file process is provably dead; deleting a live daemon''s token made its sessions permanently unadoptable after a protocol bump. - Capture agent resume records every 60s in the renderer (skipping unchanged records) so hard kills still leave a fresh resume record. * Heal blank terminals when main→renderer push delivery dies (renderer-pull delivery watchdog) Field evidence (v1.4.121-rc.0 debug snapshot, 2026-07-06): a wedged window held 530,115 un-ACKed in-flight chars — one PTY pinned at the 512KiB per-PTY high water plus a fresh terminal's 245-char prompt that was sent and never consumed — while the user ran the snapshot over invoke from that same window. Main→renderer push delivery (pty:data and every sibling channel) was dead; renderer→main→renderer invoke was alive. Upstream precedent for one-directional IPC death: electron#37067 (suspected Mojo pipe disconnect, stalled as need-info). Every terminal goes blank, new terminals are born blank, and only a renderer reload recovered. The existing recovery layers cover the OTHER variants of this bug family and structurally cannot reach this one: - The xterm write-pipeline sync-throw guards, output-buffer caps, and probe-certified replay-guard release (#7150 family) run only after bytes arrive in the renderer — here they never do. (The pending cap did work as designed in the field: ~2.1MB pendingDroppedChars, bounded main heap.) - Cumulative ACKs self-heal lost ACK messages and the solicited delivery resync reconciles verified totals (4647df86a; #7260 on main) — but the resync probe, the powerMonitor wake relay, and the droppedOutput restore markers all ride main→renderer push, the direction that is dead. The probe's unanswered path deliberately only logs. This adds the missing lane, renderer-initiated and ridden entirely over invoke — the direction the field snapshot proved alive: - terminal-delivery-watchdog.ts: 15s heartbeat, free while output flows. Hot-path cost is one Map upsert per received chunk; a tick does no IPC unless the terminal plane was silent for the whole interval and a PTY still expects delivery. Two consecutive silent ticks with main reporting ACK-starved in-flight confirm the wedge; heals are one-shot per 60s cooldown so a persisting wedge cannot repaint-storm. - pty:reportRendererDeliveryState (invoke): always max-merges the renderer's cumulative processed totals (a free extra repair lane for the lost-ACK variant); with heal:true — and only after main has itself seen ≥10s of ACK silence — writes off bytes the renderer provably never received (received ≤ acked < sent; a received-but-unparsed backpressure window is never written off), drops that PTY's pendingData (snapshot covers everything ≤ markerSeq, hidden-drop parity), credits provider flow control, and returns restore markers in the reply. - The renderer re-attaches all push listeners (cures a detached-listener variant outright; a safe no-op against a dead channel) and routes the pulled markers through the existing pty:modelRestoreNeeded machinery — panes repaint from the main-owned buffer snapshot with zero push delivery involved. - Field discrimination built in: the heal warn logs ipcRenderer.listenerCount('pty:data') (listener detached vs channel dead) with the full delivery snapshot, so the next occurrence names the root cause without asking the user to run anything in a console. Repro harness: the exposeStore-gated __terminalDeliveryWatchdog hook blackholes pty:data ahead of the dispatcher — the field failure in miniature (no receive count, no ACK credit, no dispatch). terminal-push-delivery-loss-recovery.spec.ts proves the wedged output repaints while the blackhole is still engaged and live flow resumes after release, with no reload. Unit suites pin the watchdog state machine (zero IPC under flow, two-tick confirm, cooldown), the dispatcher reattach seam, and the main-side write-off semantics. Perf: nothing added to main's send/flush path; the renderer data path gains one integer/Map update per chunk; idle cost is one ~100-byte invoke per 15s only during total terminal silence. Terminal perf e2e suite (typing latency, redraw freeze, output scheduler, hidden TUI restore, artificial opencode load) passes on this change; no watchdog activity occurs under ack-gate pressure scenarios because receive-progress gates the heartbeat. Co-authored-by: Orca * Expose the hidden-yet-visible delivery-gate contradiction in the debug snapshot The v1.4.124-rc.2.perf blank-terminal field snapshot showed a different state than the v1.4.121 transport wedge: no delivery gating at all (ackGatedFlushSkipCount 0, in-flight 38KB, far under every cap) but TWO ptys hidden-delivery-gated with 78MB dropped as hidden. The aggregate counters cannot say whether the pane the user was staring at was one of the gated ones — the one number that separates "normal background dropping" from "main is starving a visible pane because the reveal unmark never fired". Add hiddenDeliveryGatedVisiblePtyCount / hiddenDeliveryGatedActivePtyCount (overlap of the gate's hidden set with the renderer's visible/active reports — a contradiction that must be zero) to the delivery debug snapshot, and a once-per-minute warn when hidden-gated bytes are dropped for a pty the renderer reports visible or active, with the full snapshot attached. Zero cost outside the debug read and the already-dropping path. Co-authored-by: Orca * Unlatch the hidden-delivery gate when user input disproves a stuck document.visibilityState macOS occlusion tracking can wedge document.visibilityState at 'hidden' after display sleep and never fire another visibilitychange. The hidden- delivery gate then keeps dropping renderer-bound bytes for panes the user is looking at (field snapshot 2026-07-06, v1.4.124-rc.2.perf: 78MB dropped across 2 pane-level-visible ptys with a fully healthy transport), and every recovery path (window focus, system-resume relay, backlog recovery) re-ran syncHiddenRendererPtyDelivery only to recompute the same stale predicate — nothing could ever clear the gate. The user sees a frozen terminal; typing echo is dropped in main; only a reload recovers. Real user input while the document claims hidden is a physical contradiction: keystrokes and clicks only reach a focused, on-screen window. stale-document-visibility.ts latches that proof, runs each pane's existing visibilitychange resync (gate unhide + hidden-output snapshot restore), and hands authority back to the occlusion tracker on the next genuine visibilitychange. No timers; the failure bias is safe — a wrong latch can only restore pre-gate delivery cost, never drop bytes. Hot path unchanged: the foreground predicate still returns on the same single comparison while the document is visible. tests/e2e/terminal-stuck-occlusion-recovery.spec.ts pins the wedge (visibilityState pinned hidden -> output dropped, not painted; the hiddenDeliveryGatedVisiblePtyCount field discriminator reads >0) and the recovery (one Shift keypress repaints the missed output from the main-owned snapshot, no reload, while visibilityState still reads hidden). Negative control verified: the spec fails without this fix. Typing-latency perf gate passes; terminal-pane unit suites 366/366. Co-authored-by: Orca * Add a one-paste terminal freeze report: __orcaTerminalFreezeReport() Every field report of the frozen-terminal family so far has needed follow-up asks (console output, main logs, second snapshots) because each capture showed one process's counters at one instant. This makes a single DevTools command sufficient: `await window.__orcaTerminalFreezeReport()` returns renderer state (document.visibilityState + the stale-visibility override, pty:data listener count, delivery-watchdog totals), main's debug snapshot extended with a per-pty delivery table (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, window focus flags, power suspend/resume ages, app version), and bounded breadcrumb rings from BOTH processes recording the transitions that matter: gate marks/unmarks, visibilitychange and stale-visibility latches, watchdog stalls and heals, restore markers, heal write-offs, and renderer lifecycle resets (so "user already reloaded" is visible in the history). Costs stay off the data path: breadcrumbs record only rare transitions into a 100-entry ring with same-kind coalescing (a flood costs one slot per second); the per-pty table is built only when the snapshot is read; the per-send bookkeeping adds one Date.now() to existing accounting writes. Pty ids are redacted to their `@@` suffix because daemon session ids embed worktree paths. The report assembles over invoke IPC — the direction proven alive in every observed wedge — and a failing invoke is captured as data instead of sinking the report. The stuck-occlusion e2e now also pins the report end-to-end: after the wedge + keystroke recovery, the report must carry the stale-visibility latch and gate transitions in the renderer ring, gate-mark/unmark in main's ring, and a populated per-pty table. Suites: pty.test.ts 258, terminal-pane 1705, shared ring 5; typing-latency perf gate passes. Co-authored-by: Orca * perf(daemon): keep-tail thin hidden panes' stream so agent floods never bury typing (STA multi-workspace lag) Hidden panes are exempt from pendingData flow control (main gate-drops their bytes after ingestion), so N background agents ran unbounded ahead on the one shared daemon->main stream socket (measured 192MB user-space backlog) and visible-pane echo waited FIFO behind it — typing appeared seconds late whenever several agents burst on a loaded machine (8x512KB/s + 12 CPU spinners: p50 293ms fix-off; 12x1MB/s: 6.1s). Mechanism (replaces producer pacing — no reveal catch-up, ever): - Shallow socket write gate (128KB) + per-session fairness bypass bounds echo latency by construction; kernel-flush refill sentinel keeps held bulk draining at full speed (drain-only refill capped at ~8MB/s). - Backgrounded sessions' queued output is keep-tail dropped (newest 512KB kept, in-order dataGap replaces the middle); a ~2MB GLOBAL budget shrinks per-session keep-tails (floor 64KB) so a worktree switch never waits behind the aggregate. Reply-eliciting query bytes (DSR/DA/OSC probes) are salvaged from dropped spans. - Notifications are structurally lossless: the daemon runs the same shared scanners main uses (bell/OSC 133/pr-link/2031) over every byte BEFORE drop decisions and relays facts in byte order; ordered background markers hand scan authority back and forth, seeded with the emulator's partial escape tail so a sequence split across the handoff neither phantom-fires nor goes missing. Titles/agent-status stay main-side (kept-tail convergent). - Main: background = hidden AND no remote view subscriber (a live mobile/web view is never thinned); on dataGap main resets cross-chunk parse carries, drops the headless mobile mirror, and reuses the hidden-drop model-restore marker. Wire: three new stream events, tolerated within protocol v19 (old mains ignore unknown events; old daemons never see the trigger). Kill switches: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0, ORCA_DAEMON_SHALLOW_SOCKET_GATE=0. A/B (pnpm bench:multi-workspace-typing): 8x512KB/s + 12 CPU workers p50 293ms/p90 647ms -> 15/21ms (= baseline); 12x1MB/s 6,146ms -> 20ms; light loads unchanged; zero missing echoes. Latin hidden-restore e2e green (probe-verified aggregate-drain root cause). New deterministic repro harness: tests/e2e/terminal-multi-workspace-typing-latency.spec.ts + CPU pressure workers. Co-authored-by: Orca * diag(terminal): breadcrumb WebGL context-loss/atlas + wake triggers into freeze report Silent instrumentation (memory ring only, no new console lines) so the next post-wake garble report attributes itself. Adds: - shared/terminal-webgl-diagnostics.ts: lib-safe sink so pane-webgl-renderer (lib) can record without importing the components-layer ring; wired to the ring in terminal-freeze-breadcrumbs. - webgl-context-loss crumb at onContextLoss, webgl-atlas-reset crumb at the atlas registry reset — the pair that distinguishes 'atlas corrupted' from 'missed repaint'. - wake-recovery: crumb (focus/visibilitychange/system-resumed) with the clearGlyphAtlases decision; source in the kind so distinct triggers don't coalesce. - per-pane WebGL state (getAllPaneRenderingDiagnostics) in the freeze report. Gates: typecheck 0 errors; terminal suites 328 files pass; oxlint clean. Co-authored-by: Orca * fix(lint): use Number.parseInt/parseFloat in terminal-view-attributes oxlint unicorn(prefer-number-properties) flagged 24 global parseInt/parseFloat calls in the terminal-view-attributes feature (ee540f32d, perf-branch only), failing PR Checks 'verify' lint. Mechanical global→Number.* rewrite via oxlint --fix; behaviorally identical. Pre-existed the latest main merge. Co-authored-by: Orca * fix(test): update stale terminal test stubs/expectations to current runtime Three pre-existing perf-branch test failures (red before the latest main merge; unrelated to it) — all stale test scaffolding lagging behind perf-branch features, no production code changed: - provider-dispatch.test.ts: electron mock missing powerMonitor, which pty.ts installPowerSignalBreadcrumbs now calls on registerPtyHandlers. Added powerMonitor:{on:vi.fn()} to match pty.test.ts. - runtime-terminal-stream.test.ts: onSnapshot now receives a second { pendingEscapeTailAnsi } meta arg (#7329); updated the three exact-arg toHaveBeenCalledWith assertions. - terminal-multiplex-escape-tail.test.ts: stubRuntime missing registerRemoteTerminalViewSubscriber (subscribe path calls it now, erroring before snapshot serialize); added the stub used by sibling multiplex tests. Co-authored-by: Orca * Fix hidden/parked split-pane exit stranding ghost or resurrected panes Deterministic e2e repro of the field 'ghost blank pane' incident (a closed/finished setup-split leaf persisted in root with no binding and remounted as a permanently blank pane) found two teardown gaps at the hidden-view parking boundary: 1. The kept-exit guard ('freshly split pane can lose its newborn PTY during setup') fired for HIDDEN panes. The hidden-delivery gate withholds their bytes, so a hidden split always looks output-less and its dead pane was kept — a binding-less ghost that dead-session reconcile can never reach (no PTY id to prove dead). The keep is a visible-failure UX; gate it on isVisibleRef and close hidden panes. 2. A PTY exit landing while the tab is PARKED reached only the parked watcher's exit sidecar (hosts' onPtyExit requires a mounted TerminalPane), which only disposed the watcher. The leaf's stale binding then reattached on reveal and the daemon re-created the exited session id as a fresh shell — silent pane resurrection. The sidecar now collapses the dead leaf out of the stored layout via detachTerminalLayoutLeaf (the observed-exit teardown's data half). New e2e suite terminal-pane-close-layout-consistency.spec.ts sweeps close/shell-exit at every hidden/park lifecycle phase and asserts leaves(root) == bindings == live panes; all 7 scenarios pass. Unit regressions added for both fixes. Co-authored-by: Orca * Harden terminal delivery and snapshot recovery Co-authored-by: Orca * Fix inherited lint failures Co-authored-by: Orca * Align merged runtime recovery tests Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Neil Co-authored-by: Orca --- .github/workflows/release-cut.yml | 23 +- .github/workflows/release-mac-build.yml | 5 +- .gitignore | 3 + ...erm__addon-serialize@0.15.0-beta.287.patch | 55 + .../check-terminal-perf-report-budgets.mjs | 15 + ...heck-terminal-perf-report-budgets.test.mjs | 28 + .../generate-terminal-perf-html-report.mjs | 483 ++++ ...enerate-terminal-perf-html-report.test.mjs | 272 +++ config/scripts/release-rc-history.mjs | 6 +- .../run-multi-workspace-typing-bench.mjs | 63 + .../run-terminal-scale-perf-report-gate.mjs | 16 +- ...n-terminal-scale-perf-report-gate.test.mjs | 31 +- .../summarize-terminal-perf-report.mjs | 1 + config/scripts/terminal-perf-report-rows.mjs | 199 ++ .../reference/terminal-hidden-view-parking.md | 115 + .../reference/terminal-model-view-contract.md | 218 ++ docs/reference/terminal-query-authority.md | 326 +++ .../terminal-side-effect-authority.md | 252 ++ docs/terminal-main-owned-state.md | 11 + notes/garble-fuzz-divergences.md | 296 +++ notes/orca-performance-branch-guide.md | 532 +++++ notes/terminal-performance-initiative.md | 583 +++++ package.json | 5 +- pnpm-lock.yaml | 7 +- .../daemon-background-transient-facts.test.ts | 76 + .../daemon-background-transient-facts.ts | 106 + src/main/daemon/daemon-entry.ts | 4 +- src/main/daemon/daemon-errors.ts | 23 + src/main/daemon/daemon-health.test.ts | 60 +- src/main/daemon/daemon-health.ts | 91 +- src/main/daemon/daemon-init.test.ts | 232 +- src/main/daemon/daemon-init.ts | 84 +- src/main/daemon/daemon-pty-adapter.test.ts | 250 ++ src/main/daemon/daemon-pty-adapter.ts | 282 ++- src/main/daemon/daemon-pty-router.test.ts | 95 +- src/main/daemon/daemon-pty-router.ts | 42 +- src/main/daemon/daemon-server.test.ts | 66 + src/main/daemon/daemon-server.ts | 185 +- .../daemon/daemon-stream-backlog-probe.ts | 55 + .../daemon/daemon-stream-data-batcher.test.ts | 478 +++- src/main/daemon/daemon-stream-data-batcher.ts | 358 ++- src/main/daemon/daemon-stream-data-split.ts | 114 + src/main/daemon/daemon-stream-events.ts | 78 + .../daemon/daemon-stream-keep-tail-drop.ts | 182 ++ .../degraded-daemon-fallback-shutdown.ts | 25 + .../degraded-daemon-pty-provider.test.ts | 65 +- .../daemon/degraded-daemon-pty-provider.ts | 68 +- .../headless-emulator-fidelity.fuzz.test.ts | 430 ++++ src/main/daemon/headless-emulator.test.ts | 121 +- src/main/daemon/headless-emulator.ts | 396 ++-- src/main/daemon/history-manager.ts | 18 + src/main/daemon/history-reader.ts | 16 +- src/main/daemon/pty-subprocess.ts | 24 + src/main/daemon/reattach-snapshot.test.ts | 20 +- .../session-ingest-throughput.bench.test.ts | 130 + src/main/daemon/session.test.ts | 142 +- src/main/daemon/session.ts | 82 +- ...rminal-history-incremental-restore.test.ts | 7 +- src/main/daemon/terminal-host.ts | 29 +- .../terminal-mode-rehydrate-sequences.ts | 47 + src/main/daemon/terminal-mouse-mode-mirror.ts | 119 + .../daemon/terminal-osc-cwd-title-scanner.ts | 53 + .../daemon/terminal-snapshot-ansi-buffers.ts | 21 + ...minal-snapshot-serialize-roundtrip.test.ts | 254 ++ .../terminal-view-attribute-responder.ts | 191 ++ src/main/daemon/types.ts | 131 +- src/main/index.ts | 28 +- src/main/ipc/pty-hidden-delivery-gate.test.ts | 118 + src/main/ipc/pty-hidden-delivery-gate.ts | 154 ++ .../ipc/pty-producer-flow-control.test.ts | 137 ++ src/main/ipc/pty-producer-flow-control.ts | 105 + src/main/ipc/pty.test.ts | 2098 +++++++++++++++-- src/main/ipc/pty.ts | 1192 +++++++++- src/main/ipc/settings.test.ts | 21 +- src/main/ipc/settings.ts | 9 + src/main/providers/local-pty-provider.test.ts | 37 + src/main/providers/local-pty-provider.ts | 20 + src/main/providers/provider-dispatch.test.ts | 3 + src/main/providers/types.ts | 68 +- src/main/runtime/orca-runtime.test.ts | 659 ++++++ src/main/runtime/orca-runtime.ts | 1152 +++++++-- ...ned-tail-redraw-window.equivalence.test.ts | 90 + src/main/runtime/rpc/methods/terminal.ts | 357 ++- src/main/runtime/rpc/streaming.test.ts | 3 + .../terminal-multiplex-escape-tail.test.ts | 1 + .../runtime/rpc/terminal-multiplex.test.ts | 942 ++++++++ .../rpc/terminal-output-batching.test.ts | 3 + .../rpc/terminal-subscribe-buffer.test.ts | 205 +- src/main/runtime/runtime-rpc.test.ts | 209 ++ .../terminal-model-query-authority.test.ts | 140 ++ .../runtime/terminal-model-query-authority.ts | 111 + .../runtime/terminal-query-responder.test.ts | 827 +++++++ .../runtime/terminal-view-attribute-store.ts | 57 + .../ssh/ssh-relay-session-test-fixtures.ts | 48 + src/main/ssh/ssh-relay-session.test.ts | 133 +- src/main/ssh/ssh-relay-session.ts | 30 +- .../synthetic-title-frame-routing.test.ts | 24 + src/main/synthetic-title-frame-routing.ts | 14 + src/main/window/createMainWindow.test.ts | 104 + src/main/window/createMainWindow.ts | 17 + src/preload/api-types.ts | 92 +- src/preload/e2e-config.ts | 5 +- src/preload/index.ts | 121 +- src/renderer/src/App.tsx | 31 + src/renderer/src/components/Terminal.tsx | 227 +- .../TerminalPaneOverlayLayer.tsx | 27 + .../agent-task-complete-policy.ts | 66 + .../hidden-reveal-reconciliation.fuzz.test.ts | 558 +++++ .../parked-terminal-byte-watcher.test.ts | 813 +++++++ .../parked-terminal-byte-watcher.ts | 347 +++ .../parked-terminal-mode2031-responder.ts | 50 + .../terminal-pane/pty-connection-types.ts | 6 + .../terminal-pane/pty-connection.test.ts | 1597 +++++++++++-- .../terminal-pane/pty-connection.ts | 1172 ++++++--- .../pty-data-sidecar-subscriptions.ts | 6 + .../terminal-pane/pty-delivery-interest.ts | 42 + .../pty-dispatcher-delivery-interest.test.ts | 107 + .../pty-dispatcher-delivery-resync.test.ts | 92 + .../pty-dispatcher-pi-routing.test.ts | 4 +- .../pty-dispatcher-push-reattach.test.ts | 111 + .../terminal-pane/pty-dispatcher.ts | 211 +- .../pty-model-restore-channel.test.ts | 86 + .../pty-model-restore-channel.ts | 66 + .../pty-renderer-delivery-claims.test.ts | 62 + .../pty-renderer-delivery-claims.ts | 122 + .../terminal-pane/pty-transport-types.ts | 24 +- .../terminal-pane/pty-transport.test.ts | 67 + .../components/terminal-pane/pty-transport.ts | 43 +- .../terminal-pane/replay-guard.test.ts | 209 +- .../components/terminal-pane/replay-guard.ts | 128 +- .../stale-document-visibility.test.ts | 157 ++ .../stale-document-visibility.ts | 109 + .../terminal-pane/terminal-appearance.test.ts | 136 +- .../terminal-pane/terminal-appearance.ts | 149 +- .../terminal-capability-replies.ts | 70 +- .../terminal-command-lifecycle.ts | 92 +- .../terminal-delivery-watchdog.test.ts | 201 ++ .../terminal-delivery-watchdog.ts | 278 +++ .../terminal-freeze-breadcrumbs.ts | 34 + .../terminal-freeze-report.test.ts | 79 + .../terminal-pane/terminal-freeze-report.ts | 59 + .../terminal-hidden-delivery-gate.ts | 45 + ...terminal-hidden-restore-scrollback.test.ts | 14 + .../terminal-hidden-restore-scrollback.ts | 13 + .../terminal-hidden-view-parking.test.ts | 529 +++++ .../terminal-hidden-view-parking.ts | 285 +++ .../terminal-parked-tab-watchers.test.ts | 564 +++++ .../terminal-parked-tab-watchers.ts | 278 +++ .../terminal-parked-watcher-registry.ts | 107 + .../terminal-parking-e2e-overrides.test.ts | 82 + .../terminal-parking-e2e-overrides.ts | 34 + .../terminal-parser-handler-guard.test.ts | 108 + .../terminal-parser-handler-guard.ts | 44 + .../terminal-pty-ack-gate.test.ts | 123 + .../terminal-pane/terminal-pty-ack-gate.ts | 89 +- .../terminal-shutdown-layout-capture.test.ts | 56 +- .../terminal-shutdown-layout-capture.ts | 12 +- ...terminal-side-effect-facts-handler.test.ts | 509 ++++ .../terminal-side-effect-facts-handler.ts | 242 ++ .../terminal-title-tracker-parity.test.ts | 378 +++ ...terminal-view-attributes-publisher.test.ts | 249 ++ .../terminal-view-attributes-publisher.ts | 251 ++ ...inal-webgl-diagnostics-breadcrumbs.test.ts | 30 + .../use-terminal-pane-global-effects.ts | 12 +- .../use-terminal-pane-lifecycle.ts | 75 +- .../use-terminal-tab-cold-parking.ts | 243 ++ .../use-terminal-window-wake-recovery.test.ts | 114 + .../use-terminal-window-wake-recovery.ts | 25 +- .../xterm-write-buffer-stall.repro.test.ts | 79 + src/renderer/src/env.d.ts | 4 + .../lib/automation-session-observer.test.ts | 129 + .../src/lib/automation-session-observer.ts | 16 +- .../src/lib/crash-breadcrumb-recorder.ts | 25 + src/renderer/src/lib/crash-diagnostics.ts | 21 +- src/renderer/src/lib/github-links.ts | 112 +- .../launch-agent-background-session.test.ts | 55 +- .../lib/launch-agent-background-session.ts | 15 +- .../lib/pane-manager/pane-manager-registry.ts | 28 + .../pane-terminal-foreground-render-settle.ts | 28 +- .../pane-terminal-output-ack-credit.ts | 45 + ...-output-scheduler-throughput.bench.test.ts | 127 + .../pane-terminal-output-scheduler.test.ts | 298 ++- .../pane-terminal-output-scheduler.ts | 333 ++- .../lib/pane-manager/pane-webgl-renderer.ts | 5 + .../windows-pty-compatibility.test.ts | 36 +- .../pane-manager/windows-pty-compatibility.ts | 11 + .../xterm-write-callback-guard.test.ts | 110 + .../xterm-write-callback-guard.ts | 40 + ...runtime-terminal-frame-drop-resync.test.ts | 6 +- .../remote-runtime-terminal-multiplexer.ts | 157 +- .../runtime/runtime-terminal-stream.test.ts | 238 +- .../slices/agent-status-quit-capture.test.ts | 46 + src/renderer/src/store/slices/agent-status.ts | 33 +- .../src/store/slices/store-cascades.test.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 9 + .../src/store/slices/worktree-helpers.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 29 + src/shared/agent-detection.ts | 49 +- src/shared/agent-title-core.ts | 110 + src/shared/agent-title-identity.ts | 111 + src/shared/agent-title-status.ts | 203 ++ src/shared/agent-tui-ansi-fuzz-stream.ts | 279 +++ .../command-code-output-status.test.ts | 0 .../command-code-output-status.ts | 8 + .../command-code-prompt-text.ts | 0 src/shared/constants.ts | 4 + src/shared/e2e-config.ts | 13 +- src/shared/github-links.ts | 102 + src/shared/osc-title-scan-tail.test.ts | 17 + src/shared/osc-title-scan-tail.ts | 13 +- src/shared/pty-delivery-diagnostics.test.ts | 77 + src/shared/pty-delivery-diagnostics.ts | 107 + src/shared/pty-model-restore-marker.ts | 19 + src/shared/pty-renderer-delivery-health.ts | 45 + .../terminal-bell-detector.test.ts} | 2 +- .../terminal-bell-detector.ts} | 13 +- .../terminal-github-pr-link-detector.test.ts | 0 .../terminal-github-pr-link-detector.ts | 9 + .../terminal-osc133-command-finished.ts | 108 + .../terminal-output-side-effects.test.ts | 222 ++ src/shared/terminal-output-side-effects.ts | 326 +++ src/shared/terminal-reply-query-extraction.ts | 160 ++ src/shared/terminal-restore-parity-fixture.ts | 284 +++ src/shared/terminal-scrollback-policy.test.ts | 16 +- src/shared/terminal-scrollback-policy.ts | 17 + .../terminal-serialize-absolute-cursor.ts | 95 + src/shared/terminal-side-effect-facts.ts | 56 + src/shared/terminal-stream-protocol.test.ts | 15 + src/shared/terminal-stream-protocol.ts | 8 +- src/shared/terminal-view-attributes.test.ts | 119 + src/shared/terminal-view-attributes.ts | 188 ++ src/shared/terminal-webgl-diagnostics.ts | 26 + src/shared/types.ts | 22 + ...icial-opencode-hidden-pressure-scenario.ts | 130 +- ...ificial-opencode-hidden-pressure-script.ts | 52 + ...ificial-opencode-main-pressure-scenario.ts | 9 +- .../artificial-opencode-pane-interactions.ts | 119 + ...cial-opencode-revisit-pressure-scenario.ts | 311 +++ .../artificial-opencode-terminal-load.spec.ts | 279 +-- tests/e2e/global-setup.ts | 12 +- tests/e2e/global-teardown.ts | 2 +- tests/e2e/helpers/orca-app.ts | 90 +- tests/e2e/helpers/seeded-test-repo.ts | 67 + tests/e2e/helpers/terminal-input-probes.ts | 290 +++ ...erer-crash-recovery-terminal-input.spec.ts | 172 ++ .../restart-restore-terminal-input.spec.ts | 259 ++ tests/e2e/ssh-docker-relay-perf.spec.ts | 133 ++ .../sustained-agent-typing-load-scripts.ts | 135 ++ ...terminal-hidden-tui-visual-restore.spec.ts | 205 +- .../e2e/terminal-hidden-view-parking.spec.ts | 552 +++++ ...terminal-long-table-scroll-restore.spec.ts | 26 - ...nal-multi-workspace-typing-latency.spec.ts | 602 +++++ ...inal-pane-close-layout-consistency.spec.ts | 360 +++ tests/e2e/terminal-parked-memory.spec.ts | 358 +++ ...rminal-push-delivery-loss-recovery.spec.ts | 106 + ...nal-raw-emoji-table-scroll-restore.spec.ts | 8 +- tests/e2e/terminal-sleep-wake-restore.spec.ts | 220 ++ .../terminal-stuck-occlusion-recovery.spec.ts | 147 ++ tools/benchmarks/cpu-pressure-worker.mjs | 21 + .../terminal-headless-parse-bench.mjs | 70 + tools/benchmarks/terminal-pipeline-bench.mjs | 569 +++++ 261 files changed, 38055 insertions(+), 2409 deletions(-) create mode 100644 config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch create mode 100644 config/scripts/generate-terminal-perf-html-report.mjs create mode 100644 config/scripts/generate-terminal-perf-html-report.test.mjs create mode 100644 config/scripts/run-multi-workspace-typing-bench.mjs create mode 100644 config/scripts/terminal-perf-report-rows.mjs create mode 100644 docs/reference/terminal-hidden-view-parking.md create mode 100644 docs/reference/terminal-model-view-contract.md create mode 100644 docs/reference/terminal-query-authority.md create mode 100644 docs/reference/terminal-side-effect-authority.md create mode 100644 notes/garble-fuzz-divergences.md create mode 100644 notes/orca-performance-branch-guide.md create mode 100644 notes/terminal-performance-initiative.md create mode 100644 src/main/daemon/daemon-background-transient-facts.test.ts create mode 100644 src/main/daemon/daemon-background-transient-facts.ts create mode 100644 src/main/daemon/daemon-errors.ts create mode 100644 src/main/daemon/daemon-stream-backlog-probe.ts create mode 100644 src/main/daemon/daemon-stream-data-split.ts create mode 100644 src/main/daemon/daemon-stream-events.ts create mode 100644 src/main/daemon/daemon-stream-keep-tail-drop.ts create mode 100644 src/main/daemon/degraded-daemon-fallback-shutdown.ts create mode 100644 src/main/daemon/headless-emulator-fidelity.fuzz.test.ts create mode 100644 src/main/daemon/session-ingest-throughput.bench.test.ts create mode 100644 src/main/daemon/terminal-mode-rehydrate-sequences.ts create mode 100644 src/main/daemon/terminal-mouse-mode-mirror.ts create mode 100644 src/main/daemon/terminal-osc-cwd-title-scanner.ts create mode 100644 src/main/daemon/terminal-snapshot-ansi-buffers.ts create mode 100644 src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts create mode 100644 src/main/daemon/terminal-view-attribute-responder.ts create mode 100644 src/main/ipc/pty-hidden-delivery-gate.test.ts create mode 100644 src/main/ipc/pty-hidden-delivery-gate.ts create mode 100644 src/main/ipc/pty-producer-flow-control.test.ts create mode 100644 src/main/ipc/pty-producer-flow-control.ts create mode 100644 src/main/runtime/retained-tail-redraw-window.equivalence.test.ts create mode 100644 src/main/runtime/terminal-model-query-authority.test.ts create mode 100644 src/main/runtime/terminal-model-query-authority.ts create mode 100644 src/main/runtime/terminal-query-responder.test.ts create mode 100644 src/main/runtime/terminal-view-attribute-store.ts create mode 100644 src/main/ssh/ssh-relay-session-test-fixtures.ts create mode 100644 src/main/synthetic-title-frame-routing.test.ts create mode 100644 src/main/synthetic-title-frame-routing.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-delivery-interest.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts create mode 100644 src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/stale-document-visibility.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-report.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts create mode 100644 src/renderer/src/lib/automation-session-observer.test.ts create mode 100644 src/renderer/src/lib/crash-breadcrumb-recorder.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts create mode 100644 src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts create mode 100644 src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts create mode 100644 src/shared/agent-title-core.ts create mode 100644 src/shared/agent-title-identity.ts create mode 100644 src/shared/agent-title-status.ts create mode 100644 src/shared/agent-tui-ansi-fuzz-stream.ts rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.test.ts (100%) rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.ts (95%) rename src/{renderer/src/components/terminal-pane => shared}/command-code-prompt-text.ts (100%) create mode 100644 src/shared/github-links.ts create mode 100644 src/shared/osc-title-scan-tail.test.ts create mode 100644 src/shared/pty-delivery-diagnostics.test.ts create mode 100644 src/shared/pty-delivery-diagnostics.ts create mode 100644 src/shared/pty-model-restore-marker.ts create mode 100644 src/shared/pty-renderer-delivery-health.ts rename src/{renderer/src/components/terminal-pane/bell-detector.test.ts => shared/terminal-bell-detector.test.ts} (93%) rename src/{renderer/src/components/terminal-pane/bell-detector.ts => shared/terminal-bell-detector.ts} (82%) rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.test.ts (100%) rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.ts (91%) create mode 100644 src/shared/terminal-osc133-command-finished.ts create mode 100644 src/shared/terminal-output-side-effects.test.ts create mode 100644 src/shared/terminal-output-side-effects.ts create mode 100644 src/shared/terminal-reply-query-extraction.ts create mode 100644 src/shared/terminal-restore-parity-fixture.ts create mode 100644 src/shared/terminal-serialize-absolute-cursor.ts create mode 100644 src/shared/terminal-side-effect-facts.ts create mode 100644 src/shared/terminal-view-attributes.test.ts create mode 100644 src/shared/terminal-view-attributes.ts create mode 100644 src/shared/terminal-webgl-diagnostics.ts create mode 100644 tests/e2e/artificial-opencode-hidden-pressure-script.ts create mode 100644 tests/e2e/artificial-opencode-pane-interactions.ts create mode 100644 tests/e2e/artificial-opencode-revisit-pressure-scenario.ts create mode 100644 tests/e2e/helpers/seeded-test-repo.ts create mode 100644 tests/e2e/helpers/terminal-input-probes.ts create mode 100644 tests/e2e/renderer-crash-recovery-terminal-input.spec.ts create mode 100644 tests/e2e/restart-restore-terminal-input.spec.ts create mode 100644 tests/e2e/sustained-agent-typing-load-scripts.ts create mode 100644 tests/e2e/terminal-hidden-view-parking.spec.ts create mode 100644 tests/e2e/terminal-multi-workspace-typing-latency.spec.ts create mode 100644 tests/e2e/terminal-pane-close-layout-consistency.spec.ts create mode 100644 tests/e2e/terminal-parked-memory.spec.ts create mode 100644 tests/e2e/terminal-push-delivery-loss-recovery.spec.ts create mode 100644 tests/e2e/terminal-sleep-wake-restore.spec.ts create mode 100644 tests/e2e/terminal-stuck-occlusion-recovery.spec.ts create mode 100644 tools/benchmarks/cpu-pressure-worker.mjs create mode 100644 tools/benchmarks/terminal-headless-parse-bench.mjs create mode 100644 tools/benchmarks/terminal-pipeline-bench.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 77e1dbcb7f8..397772bfb7e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -41,6 +41,11 @@ on: required: false default: false type: boolean + version_suffix: + description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only. + required: false + type: string + default: '' permissions: contents: write @@ -192,6 +197,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} + VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }} run: | set -euo pipefail @@ -366,6 +372,18 @@ jobs: fi new="${base}-rc.$((highest_rc + 1))" fi + if [[ -n "${VERSION_SUFFIX:-}" ]]; then + # Why a dot-appended identifier (rc.N.perf): it sorts just + # above its own base rc.N but BELOW rc.N+1, so suffixed side- + # branch builds never outrank the main RC series and cannot + # hijack the update channel; clients find them by matching the + # identifier ("perf") in the prerelease components. + if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then + echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2 + exit 1 + fi + new="${new}.${VERSION_SUFFIX}" + fi ;; patch|minor|major) new="$(bump "$latest_stable" "$KIND")" @@ -808,7 +826,10 @@ jobs: TAG: ${{ needs.cut.outputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index e82502baa47..3638d40f00c 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -96,7 +96,10 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.gitignore b/.gitignore index 50c46ec9f8d..ed81a687a69 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json + +# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md +tools/benchmarks/results/terminal-pipeline-*.json diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch new file mode 100644 index 00000000000..eab0cd58452 --- /dev/null +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch @@ -0,0 +1,55 @@ +diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js +index d669293d06d801ef472d5c2454ce86876e5c321f..96bb66cdc05289b6a7ce5ed64fc833624c885c57 100644 +--- a/lib/addon-serialize.js ++++ b/lib/addon-serialize.js +@@ -1,2 +1,2 @@ +-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); + //# sourceMappingURL=addon-serialize.js.map +\ No newline at end of file +diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..475e45b3ed228027531deea5530bd03859f4a4e0 100644 +--- a/lib/addon-serialize.mjs ++++ b/lib/addon-serialize.mjs +@@ -15,5 +15,5 @@ + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r +-`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; + //# sourceMappingURL=addon-serialize.mjs.map +diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts +index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbeccd43cfaae 100644 +--- a/src/SerializeAddon.ts ++++ b/src/SerializeAddon.ts +@@ -310,7 +310,20 @@ class StringSerializeHandler extends BaseSerializeHandler { + } + if (flagsChanged) { + if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } +- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } ++ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so ++ // they must be diffed as one intensity group with the clearing 22 emitted ++ // BEFORE any re-set. Upstream's independent per-flag diff could emit ++ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB ++ // loses B's bold on round-trip) or a bare "22" that drops a still-set ++ // bold/dim, garbling Orca's hidden-terminal snapshot restores. ++ const boldChanged = cell.isBold() !== oldCell.isBold(); ++ const dimChanged = cell.isDim() !== oldCell.isDim(); ++ if (boldChanged || dimChanged) { ++ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim()); ++ if (clearsIntensity) { sgrSeq.push(22); } ++ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); } ++ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); } ++ } + if (!equalUnderline(cell, oldCell)) { + const style = cell.getUnderlineStyle(); + if (style === UnderlineStyle.NONE) { +@@ -337,7 +350,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } + if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } + if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } +- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); } ++ // PATCH(orca): dim handled in the intensity group above. + if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } + } + } diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 693822373f0..dd7185189d8 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -18,6 +18,7 @@ if (reportPaths.length === 0) { const BUDGETS = { maxMedianKeyLatencyMs: 75, maxWorstKeyLatencyMs: 300, + maxRevisitLatencyMs: 300, maxTimerDriftMs: 150, maxScrollLatencyMs: 150, maxRestoreLatencyMs: 1000, @@ -80,6 +81,12 @@ function validateRow(row) { BUDGETS.maxWorstKeyLatencyMs, 'ms' ) + addBudgetCheck( + 'revisit latency', + parseMs(row.revisit, 'revisit', row, failures), + BUDGETS.maxRevisitLatencyMs, + 'ms' + ) addBudgetCheck( 'timer drift', parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures), @@ -113,6 +120,14 @@ function validateRow(row) { parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures), BUDGETS.maxRendererDroppedBacklogs ) + // Why: parked-memory rows carry heap/view-count metrics with no latency + // budget; recognize them so memory-only scenarios pass the gate instead of + // tripping the "no recognized budget metrics" guard. + for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) { + if (parseCount(row[fieldName], fieldName, row, failures) != null) { + checkedMetricCount += 1 + } + } if (checkedMetricCount === 0) { failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`) } diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index d1fd378b498..e6129975151 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=180', 'median=2.9ms', 'worst=5.9ms', + 'revisit=42.0ms', 'maxTimerDrift=12.1ms', 'scroll=149.9ms', 'restore=642.0ms', @@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=60', 'median=76.0ms', 'worst=301.0ms', + 'revisit=301.0ms', 'maxTimerDrift=151.0ms', 'scroll=151.0ms', 'restore=1001.0ms', @@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms') expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms') + expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms') expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms') expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms') expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms') @@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.stderr).toContain('no recognized budget metrics found') }) + it('accepts revisit-only marker rows as budgeted perf evidence', () => { + const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184') + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + + it('accepts parked-memory rows that carry only heap and view-count metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + it('fails OpenCode annotation rows that contain no budget metrics', () => { const reportPath = writeReport('panes=1 frames=60') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs new file mode 100644 index 00000000000..577592169bc --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -0,0 +1,483 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { + budgetFailures, + collectTerminalPerfRows, + compareScenarios, + escapeHtml, + formatLargeValue, + formatMs, + readJsonReport, + scenarioTitle +} from './terminal-perf-report-rows.mjs' +import { basename, dirname } from 'node:path' + +const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' + +// Why: every tracked metric is lower-is-better, so delta coloring and the +// regression table share one direction rule. +const MS_METRICS = [ + { key: 'medianMs', label: 'Typing median', chart: true }, + { key: 'worstMs', label: 'Typing worst', chart: true }, + { key: 'scrollMs', label: 'Active scroll', chart: true }, + { key: 'restoreMs', label: 'Restore', chart: true }, + { key: 'revisitMs', label: 'Revisit marker', chart: true }, + { key: 'maxTimerDriftMs', label: 'Timer drift', chart: false } +] + +const COUNT_METRICS = [ + { key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' }, + { key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, + { key: 'mainPeakPendingChars', label: 'Main pending chars' }, + { key: 'hiddenSkippedChars', label: 'Hidden skipped chars' }, + { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' }, + // Why: parked-memory scenarios are table-only — heap/view counts have no + // ms trend story, so they stay out of the charts. + { key: 'heapUsedMB', label: 'Renderer JS heap (MB)' }, + { key: 'liveTerminals', label: 'Live xterm instances' }, + { key: 'livePaneManagers', label: 'Live pane managers' } +] + +const SERIES_COLORS = { + medianMs: '#2563eb', + worstMs: '#dc2626', + scrollMs: '#d97706', + restoreMs: '#7c3aed', + revisitMs: '#0d9488' +} + +const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/ + +export function parseHtmlReportArgs(argv, env = process.env) { + const args = [...argv] + if (args[0] === '--') { + args.shift() + } + + const inputs = [] + let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--output' || arg === '-o') { + const next = args[index + 1] + if (!next || next.startsWith('-')) { + throw new Error(`${arg} requires a path`) + } + outputPath = next + index += 1 + continue + } + if (arg.startsWith('--output=')) { + outputPath = arg.slice('--output='.length) + continue + } + const labeled = arg.match(LABELED_INPUT_RE) + if (labeled) { + inputs.push({ label: labeled[1], path: labeled[2] }) + } else { + inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg }) + } + } + + if (inputs.length === 0) { + throw new Error( + 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]... --output ' + ) + } + return { inputs, outputPath } +} + +// ── Trend data ──────────────────────────────────────────────────────────── + +function buildMatrix(revisions) { + const scenarios = new Map() + for (const revision of revisions) { + for (const row of revision.rows) { + if (!scenarios.has(row.scenario)) { + scenarios.set(row.scenario, new Map()) + } + scenarios.get(row.scenario).set(revision.label, row) + } + } + const orderedScenarios = [...scenarios.keys()].sort(compareScenarios) + return { scenarios, orderedScenarios } +} + +function niceCeil(value) { + if (value <= 0) { + return 1 + } + const magnitude = 10 ** Math.floor(Math.log10(value)) + for (const step of [1, 2, 2.5, 5, 10]) { + if (value <= step * magnitude) { + return step * magnitude + } + } + return 10 * magnitude +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +function renderTrendChart({ scenario, byRevision, revisions, title }) { + const metrics = MS_METRICS.filter( + (metric) => + metric.chart && + revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null) + ) + if (metrics.length === 0) { + return '' + } + const width = 560 + const height = 230 + const pad = { left: 52, right: 14, top: 30, bottom: 38 } + const plotW = width - pad.left - pad.right + const plotH = height - pad.top - pad.bottom + const maxValue = Math.max( + 1, + ...metrics.flatMap((metric) => + revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0) + ) + ) + const yMax = niceCeil(maxValue * 1.15) + const xFor = (index) => + pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1)) + const yFor = (value) => pad.top + plotH - (plotH * value) / yMax + + const parts = [] + parts.push( + `` + ) + parts.push(`${escapeHtml(title)}`) + // Horizontal gridlines + y labels + const ticks = 4 + for (let tick = 0; tick <= ticks; tick += 1) { + const value = (yMax * tick) / ticks + const y = yFor(value) + parts.push( + `` + ) + parts.push( + `${value % 1 === 0 ? value : value.toFixed(1)}` + ) + } + // X labels + revisions.forEach((revision, index) => { + parts.push( + `${escapeHtml(revision.label)}` + ) + }) + // Series + for (const metric of metrics) { + const color = SERIES_COLORS[metric.key] ?? '#475569' + const points = revisions + .map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] })) + .filter((point) => point.value != null) + if (points.length === 0) { + continue + } + const path = points + .map( + (point, order) => + `${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}` + ) + .join(' ') + parts.push(``) + for (const point of points) { + const x = xFor(point.index) + const y = yFor(point.value) + parts.push(``) + parts.push( + `${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}` + ) + } + } + parts.push('') + + const legend = metrics + .map((metric) => { + const color = SERIES_COLORS[metric.key] ?? '#475569' + return `${escapeHtml(metric.label)}` + }) + .join('') + return `
${parts.join('')}
${legend} ms — lower is better
` +} + +function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) { + if (baseline == null || latest == null) { + return '—' + } + const diff = latest - baseline + const pct = baseline === 0 ? null : (diff / baseline) * 100 + let cls = 'neutral' + if (zeroBudget) { + cls = latest > 0 ? 'worse' : 'better' + } else if (pct != null && Math.abs(pct) >= 5) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } else if (baseline === 0 && diff !== 0) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } + const pctLabel = + pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%` + const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}` + return `${escapeHtml(pctLabel)} (${escapeHtml(diffLabel)})` +} + +function renderScenarioTable({ scenario, byRevision, revisions, title }) { + const metricRows = [] + const allMetrics = [...MS_METRICS, ...COUNT_METRICS] + for (const metric of allMetrics) { + const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key]) + if (values.every((value) => value == null)) { + continue + } + const isMs = MS_METRICS.includes(metric) + const format = isMs ? formatMs : formatLargeValue + const cells = values + .map((value) => `${value == null ? '—' : escapeHtml(format(value))}`) + .join('') + const baseline = values.find((value) => value != null) + const latest = values.toReversed().find((value) => value != null) + metricRows.push( + `${escapeHtml(metric.label)}${cells}${deltaCell(baseline, latest, { + zeroBudget: metric.key === 'rendererDroppedBacklogs' + })}` + ) + } + if (metricRows.length === 0) { + return '' + } + const headers = revisions.map((revision) => `${escapeHtml(revision.label)}`).join('') + return `
+

${escapeHtml(title)} ${escapeHtml(scenario)}

+ +${headers} +${metricRows.join('')} +
MetricΔ first → last
+
` +} + +function renderHeadline(revisions, matrix) { + if (revisions.length < 2) { + return '' + } + const first = revisions[0] + const last = revisions.at(-1) + const cards = [] + for (const scenario of matrix.orderedScenarios) { + const byRevision = matrix.scenarios.get(scenario) + const baseRow = byRevision.get(first.label) + const lastRow = byRevision.get(last.label) + if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) { + continue + } + const diff = lastRow.medianMs - baseRow.medianMs + const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100 + const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse' + cards.push(`
+
${escapeHtml(scenarioTitle(scenario, lastRow))}
+
${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}
+
typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)
+
`) + } + if (cards.length === 0) { + return '' + } + return `

Baseline vs latest

${cards.join('')}
` +} + +function renderBudgets(latestRevision) { + const failures = [] + for (const row of latestRevision.rows) { + for (const failure of budgetFailures(row)) { + failures.push(`${row.scenario}: ${failure}`) + } + } + const status = + failures.length === 0 ? 'Pass' : 'Fail' + const failureList = + failures.length === 0 + ? '' + : `
    ${failures.map((failure) => `
  • ${escapeHtml(failure)}
  • `).join('')}
` + return `

Budget status — ${escapeHtml(latestRevision.label)}

+

${latestRevision.rows.length} scenario rows checked: ${status}

${failureList}
` +} + +function renderInputsMeta(revisions) { + const items = revisions + .map((revision) => { + const stats = revision.stats + const statsLabel = stats + ? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky` + : '' + const failNote = + stats && stats.unexpected > 0 + ? ' (failed assertions at this revision; metrics still recorded)' + : '' + return `
  • ${escapeHtml(revision.label)} — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +function renderRawDetails(revisions) { + return revisions + .map((revision) => { + const rows = revision.rows + .map( + (row) => + `${escapeHtml(row.scenario)}${row.panes ?? '—'}${escapeHtml(formatMs(row.medianMs))}${escapeHtml(formatMs(row.worstMs))}${escapeHtml(formatMs(row.scrollMs))}${escapeHtml(formatMs(row.restoreMs))}${escapeHtml(formatMs(row.revisitMs))}${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}${row.rendererDroppedBacklogs ?? '—'}` + ) + .join('') + return `
    Raw rows — ${escapeHtml(revision.label)} + + +${rows}
    ScenarioPanesMedianWorstScrollRestoreRevisitRenderer peakHidden skippedDrops
    ` + }) + .join('') +} + +const PAGE_CSS = ` +:root { color-scheme: light; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; } +h1 { font-size: 24px; margin-bottom: 4px; } +h2 { font-size: 18px; margin: 28px 0 10px; } +h3 { font-size: 15px; margin: 18px 0 6px; } +.meta { color: #64748b; font-size: 13px; } +.inputs { font-size: 13px; color: #334155; padding-left: 20px; } +.meta-warn { color: #b45309; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; } +.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; } +.card.better { border-left-color: #16a34a; } +.card.worse { border-left-color: #dc2626; } +.card.neutral { border-left-color: #94a3b8; } +.card-title { font-size: 12px; color: #64748b; } +.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; } +.card-sub { font-size: 11px; color: #94a3b8; } +.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; } +.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; } +.trend-chart { width: 100%; height: auto; } +.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; } +.gridline { stroke: #e2e8f0; stroke-width: 1; } +.axis-label { font-size: 10px; fill: #64748b; } +.point-label { font-size: 10px; font-weight: 600; } +.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.legend-item { display: inline-flex; align-items: center; gap: 4px; } +.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } +.legend-unit { color: #94a3b8; margin-left: auto; } +.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; } +.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; } +table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; } +table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; } +table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; } +table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; } +td.delta.better { color: #15803d; font-weight: 600; } +td.delta.worse { color: #b91c1c; font-weight: 600; } +td.delta.neutral { color: #64748b; } +.delta-abs { font-weight: 400; color: #94a3b8; } +.pass { color: #15803d; font-weight: 700; } +.fail { color: #b91c1c; font-weight: 700; } +details { margin: 8px 0; } +summary { cursor: pointer; font-size: 13px; color: #334155; } +` + +function renderHtml({ generatedAt, revisions }) { + const matrix = buildMatrix(revisions) + const charts = + revisions.length >= 2 + ? matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderTrendChart({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + : '' + const tables = matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderScenarioTable({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + + return ` + + + + +Terminal Performance Over Time + + + +

    Terminal Performance Over Time

    +

    Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.

    +${renderInputsMeta(revisions)} +${renderHeadline(revisions, matrix)} +${charts ? `

    Trends across revisions

    ${charts}
    ` : ''} +

    Metric detail by scenario

    ${tables}
    +${renderBudgets(revisions.at(-1))} +

    Raw data

    ${renderRawDetails(revisions)}
    + + +` +} + +export function generateTerminalPerfHtmlReport({ + inputs, + inputPaths, + outputPath, + now = new Date() +}) { + // Why: older callers (the scale report gate) pass bare inputPaths. + const resolvedInputs = + inputs ?? + (inputPaths ?? []).map((path) => ({ + label: basename(path).replace(/\.json$/i, ''), + path + })) + const revisions = resolvedInputs.map(({ label, path }) => { + const report = readJsonReport(path) + return { + label, + path, + stats: report.stats ?? null, + rows: collectTerminalPerfRows(report, label) + } + }) + const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0) + if (totalRows === 0) { + throw new Error('No opencode terminal perf annotations found in the provided reports') + } + const html = renderHtml({ generatedAt: now.toISOString(), revisions }) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, html) + const latestFailures = revisions + .at(-1) + .rows.reduce((sum, row) => sum + budgetFailures(row).length, 0) + return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures } +} + +const isMain = process.argv[1] && import.meta.filename === process.argv[1] +if (isMain) { + try { + const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2)) + const result = generateTerminalPerfHtmlReport({ inputs, outputPath }) + console.log( + `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs new file mode 100644 index 00000000000..a804bb6639d --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -0,0 +1,272 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + generateTerminalPerfHtmlReport, + parseHtmlReportArgs +} from './generate-terminal-perf-html-report.mjs' + +const tempDirs = [] + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-')) + tempDirs.push(dir) + return dir +} + +function writeReport( + annotationDescription, + annotationType = 'opencode-scale-same-workspace-25', + reportName = 'report.json' +) { + const dir = makeTempDir() + const reportPath = join(dir, reportName) + writeFileSync( + reportPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: annotationType, + description: annotationDescription + }, + { + type: 'browser-unrelated', + description: 'median=999.0ms' + } + ] + } + ] + } + ] + } + ] + }) + ) + return reportPath +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('generate-terminal-perf-html-report', () => { + it('parses labeled and bare input paths plus output flags', () => { + expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({ + inputs: [ + { label: 'a', path: 'a.json' }, + { label: 'b', path: 'b.json' } + ], + outputPath: 'out.html' + }) + expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual( + { + inputs: [ + { label: 'main', path: 'runs/0-main.json' }, + { label: '#5038 final', path: 'runs/4-final.json' } + ], + outputPath: 'test-results/terminal-perf-impact-report.html' + } + ) + expect( + parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' }) + ).toEqual({ + inputs: [{ label: 'a', path: 'a.json' }], + outputPath: 'env.html' + }) + expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path') + expect(() => parseHtmlReportArgs([])).toThrow('Usage:') + }) + + it('writes a single-run report with scenario tables and budget status', () => { + const reportPath = writeReport( + [ + 'panes=25', + 'frames=60', + 'median=12.4ms', + 'worst=44.8ms', + 'revisit=28.6ms', + 'scroll=61.0ms', + 'restore=320.0ms', + 'maxTimerDrift=8.0ms', + 'rendererPeakQueuedChars=2048', + 'mainPeakInFlightChars=4096', + 'heldAckChars=1024', + 'hiddenSkippedChars=512', + 'rendererDroppedBacklogs=0' + ].join(' ') + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath, + now: new Date('2026-06-09T10:00:00.000Z') + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 }) + expect(html).toContain('') + expect(html).toContain('Terminal Performance Over Time') + expect(html).toContain('2026-06-09T10:00:00.000Z') + expect(html).toContain('Same workspace panes — 25 panes') + expect(html).toContain('opencode-scale-same-workspace-25') + expect(html).toContain('28.6ms') + expect(html).toContain('Pass') + // Why: one run has no over-time story; the trend section must not render. + expect(html).not.toContain('Trends across revisions') + expect(html).not.toContain('browser-unrelated') + }) + + it('renders parked-memory heap and live view counts as table metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + // Why: heapUsedMB has no budget — a memory row alone must not fail gates. + expect(result.budgetFailureCount).toBe(0) + expect(html).toContain('Parked hidden terminal memory — 8 panes') + expect(html).toContain('Renderer JS heap (MB)') + expect(html).toContain('142.5') + expect(html).toContain('Live xterm instances') + expect(html).toContain('Live pane managers') + }) + + it('marks over-budget rows as failures for the latest run', () => { + const reportPath = writeReport( + [ + 'panes=100', + 'median=80.0ms', + 'worst=301.0ms', + 'revisit=301.0ms', + 'rendererPeakQueuedChars=2097153', + 'rendererDroppedBacklogs=1' + ].join(' '), + 'opencode-scale-cross-workspace-100' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.budgetFailureCount).toBe(5) + expect(html).toContain('Fail') + expect(html).toContain('medianMs 80 > 75') + expect(html).toContain('Cross-workspace hidden panes') + }) + + it('renders ordered revisions with trend charts and baseline deltas', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const middleReport = writeReport( + 'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'backpressure.json' + ) + const finalReport = writeReport( + 'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'final.json' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'backpressure', path: middleReport }, + { label: 'final', path: finalReport } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.rowCount).toBe(3) + expect(html).toContain('Baseline vs latest') + expect(html).toContain('Trends across revisions') + expect(html).toContain('trend-chart') + expect(html).toContain('>main<') + expect(html).toContain('>backpressure<') + expect(html).toContain('>final<') + // Why: median 50 -> 20 is a 60% improvement and must read as better. + expect(html).toContain('delta better') + expect(html).toContain('-60%') + expect(html).toContain('50.0ms → 20.0ms') + }) + + it('renders missing scenarios at older revisions as gaps, not zeros', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const finalReport = makeTempDir() + const finalPath = join(finalReport, 'final.json') + writeFileSync( + finalPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-25', + description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0' + }, + { + type: 'opencode-revisit-pressure', + description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0' + } + ] + } + ] + } + ] + } + ] + }) + ) + const outputPath = join(makeTempDir(), 'report.html') + + generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'final', path: finalPath } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(html).toContain('Revisit under pressure') + expect(html).toContain('—') + }) + + it('fails when reports contain no terminal perf annotations', () => { + const reportPath = writeReport('median=12.0ms', 'browser-unrelated') + + expect(() => + generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath: join(makeTempDir(), 'report.html') + }) + ).toThrow('No opencode terminal perf annotations found') + }) +}) diff --git a/config/scripts/release-rc-history.mjs b/config/scripts/release-rc-history.mjs index e3cdea2d870..c6a0e7b4782 100644 --- a/config/scripts/release-rc-history.mjs +++ b/config/scripts/release-rc-history.mjs @@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) { } const suffix = tag.slice(prefix.length) - return /^\d+$/.test(suffix) ? Number(suffix) : null + // Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf) + // must advance the shared rc counter, or the next suffixed cut recomputes + // an existing tag and the workflow refuses to re-cut over it. + const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix) + return match ? Number(match[1]) : null } export function rcNumberFromReleaseSubject(base, subject) { diff --git a/config/scripts/run-multi-workspace-typing-bench.mjs b/config/scripts/run-multi-workspace-typing-bench.mjs new file mode 100644 index 00000000000..7208afb470a --- /dev/null +++ b/config/scripts/run-multi-workspace-typing-bench.mjs @@ -0,0 +1,63 @@ +/** + * Entry point for the multi-workspace typing-latency bench + * (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts). + * + * Usage: + * pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \ + * --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix] + * + * Results land in tools/benchmarks/results/multi-workspace-typing-*.json. + * Run once per build/config with distinct --label values, then diff the + * totalMs/inputHalfMs/echoHalfMs percentiles. + */ +import { spawn } from 'node:child_process' + +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' + +const knobByFlag = { + '--panes': 'ORCA_TYPING_BENCH_LOAD_PANES', + '--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS', + '--keys': 'ORCA_TYPING_BENCH_KEYS', + '--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS', + '--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS', + '--label': 'ORCA_TYPING_BENCH_LABEL' +} + +const env = { ...process.env, ORCA_TYPING_BENCH: '1' } +const passthroughArgs = [] +const argv = process.argv.slice(2) +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--') { + continue + } + const knob = knobByFlag[argv[i]] + if (knob) { + env[knob] = argv[++i] + } else { + passthroughArgs.push(argv[i]) + } +} + +const child = spawn( + npxCommand, + [ + 'playwright', + 'test', + 'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...passthroughArgs + ], + { stdio: 'inherit', env } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) diff --git a/config/scripts/run-terminal-scale-perf-report-gate.mjs b/config/scripts/run-terminal-scale-perf-report-gate.mjs index eef60041da1..59b1b443fa9 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json' +const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html' export function parseReportGateArgs(argv, env = process.env) { const forwardedArgs = [...argv] @@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({ spawnSyncImpl, env ) - return exitCode(budgetResult) + const budgetExitCode = exitCode(budgetResult) + if (budgetExitCode !== 0) { + return budgetExitCode + } + + const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH + const htmlResult = runNodeScript( + 'config/scripts/generate-terminal-perf-html-report.mjs', + [reportPath, '--output', htmlReportPath], + 'inherit', + spawnSyncImpl, + env + ) + return exitCode(htmlResult) } if (process.argv[1] === import.meta.filename) { diff --git a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs index 929e2d5b9ba..e4d7be8861e 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs @@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls.map((call) => call.args[0])).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'config/scripts/summarize-terminal-perf-report.mjs', - 'config/scripts/check-terminal-perf-report-budgets.mjs' + 'config/scripts/check-terminal-perf-report-budgets.mjs', + 'config/scripts/generate-terminal-perf-html-report.mjs' ]) expect(calls[0].args).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', @@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => { 'config/scripts/check-terminal-perf-report-budgets.mjs', reportPath ]) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'test-results/terminal-perf-impact-report.html' + ]) }) it('uses the report path from env when no flag is provided', () => { @@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath]) }) + it('uses the HTML report path from env when provided', () => { + const reportPath = tempReportPath() + const { calls, spawnSyncImpl } = makeSpawnSync() + + const status = runTerminalScalePerfReportGate({ + env: { + ...process.env, + ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html', + ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath + }, + spawnSyncImpl + }) + + expect(status).toBe(0) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'tmp/terminal-report.html' + ]) + }) + it('preserves the report when Playwright clears the target report directory', () => { const reportPath = tempReportPath() const { spawnSyncImpl } = makeSpawnSync({ diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 6d2adea54f9..2e4f77732b3 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -25,6 +25,7 @@ function printMarkdownTable(rows) { ['Frames', 'frames'], ['Median', 'median'], ['Worst', 'worst'], + ['Revisit', 'revisit'], ['Scroll', 'scroll'], ['Restore', 'restore'], ['Max Drift', 'maxTimerDrift'], diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs new file mode 100644 index 00000000000..c1f562f3b9e --- /dev/null +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs' + +const BUDGETS = { + medianMs: 75, + worstMs: 300, + revisitMs: 300, + maxTimerDriftMs: 150, + scrollMs: 150, + restoreMs: 1000, + rendererQueuedChars: 2 * 1024 * 1024, + rendererPeakQueuedChars: 2 * 1024 * 1024, + rendererDroppedBacklogs: 0 +} + +const SCENARIO_LABELS = [ + ['opencode-scale-same-workspace', 'Same workspace panes'], + ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], + ['opencode-scale-pressure', 'ACK-backpressured PTYs'], + ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], + ['opencode-cross-workspace-typing', 'Cross-workspace typing'], + ['opencode-main-pressure', 'Main renderer pressure'], + ['opencode-hidden-pressure', 'Hidden pressure'], + ['opencode-revisit-pressure', 'Revisit under pressure'], + // Why: the prefix also matches opencode-parked-memory-disabled, so both + // parked-memory scenarios group under one label. + ['opencode-parked-memory', 'Parked hidden terminal memory'] +] + +export function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +export function collectTerminalPerfRows(report, source) { + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith('opencode-')) { + continue + } + rows.push( + normalizeRow({ + source, + scenario: annotation.type, + ...parseAnnotationDescription(annotation.description ?? '') + }) + ) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} + +function parseMs(value) { + const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) + return match ? Number(match[1]) : null +} + +function parseCount(value) { + if (value == null || value === '') { + return null + } + const count = Number(value) + return Number.isFinite(count) ? count : null +} + +function normalizeRow(row) { + return { + ...row, + group: scenarioGroup(row.scenario), + panes: parseCount(row.panes), + frames: parseCount(row.frames), + medianMs: parseMs(row.median), + worstMs: parseMs(row.worst), + revisitMs: parseMs(row.revisit), + maxTimerDriftMs: parseMs(row.maxTimerDrift), + scrollMs: parseMs(row.scroll), + restoreMs: parseMs(row.restore), + rendererQueuedChars: parseCount(row.rendererQueuedChars), + rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), + rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), + mainPeakPendingChars: parseCount(row.mainPeakPendingChars), + mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), + heldAckChars: parseCount(row.heldAckChars), + hiddenSkippedChars: parseCount(row.hiddenSkippedChars), + // Why: parked-memory annotations report a fractional MB heap figure plus + // live renderer view counts; Number() keeps the MB float intact. + heapUsedMB: parseCount(row.heapUsedMB), + liveTerminals: parseCount(row.liveTerminals), + livePaneManagers: parseCount(row.livePaneManagers) + } +} + +export function scenarioGroup(scenario) { + for (const [prefix, label] of SCENARIO_LABELS) { + if (scenario.startsWith(prefix)) { + return label + } + } + return 'Other terminal scenarios' +} + +function scenarioSortKey(scenario) { + const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) + const paneMatch = scenario.match(/-(\d+)$/) + return [ + prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, + paneMatch ? Number(paneMatch[1]) : 0, + scenario + ] +} + +export function compareScenarios(a, b) { + const ka = scenarioSortKey(a) + const kb = scenarioSortKey(b) + if (ka[0] !== kb[0]) { + return ka[0] - kb[0] + } + if (ka[1] !== kb[1]) { + return ka[1] - kb[1] + } + return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 +} + +export function scenarioTitle(scenario, row) { + const group = scenarioGroup(scenario) + if (row?.panes != null) { + return `${group} — ${row.panes} panes` + } + return group +} + +export function budgetFailures(row) { + const failures = [] + for (const [key, budget] of Object.entries(BUDGETS)) { + const value = row[key] + if (value == null) { + continue + } + if (value > budget) { + failures.push(`${key} ${value} > ${budget}`) + } + } + return failures +} + +export function formatMs(value) { + if (value == null) { + return '—' + } + return `${value.toFixed(1)}ms` +} + +export function formatLargeValue(value) { + if (value == null) { + return '—' + } + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)}M` + } + if (value >= 1024) { + return `${Math.round(value / 1024)}k` + } + return String(value) +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md new file mode 100644 index 00000000000..eeaf17877ea --- /dev/null +++ b/docs/reference/terminal-hidden-view-parking.md @@ -0,0 +1,115 @@ +# Terminal Hidden View Parking + +Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch +`terminalHiddenViewParking` (default on). See +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the +invariants this design extends and the full phase list. + +## Problem + +Hidden terminal panes keep a full renderer xterm instance alive (buffer, +scrollback, DOM, addons). At many-worktree scale this is the dominant renderer +memory cost, and it forces every hidden byte through renderer-side write/skip +decisions. The main-process model (daemon + runtime headless emulators) already +ingests every byte and can serve restorable snapshots, so the renderer view for +a long-hidden pane is redundant state. + +A previous attempt shipped and was reverted the same day. The post-mortem +finding: parking unmounted the pane component, which also tore down the +renderer's PTY byte parsers — and those parsers are the only source of bell +notifications, title-transition agent-complete notifications, and tab titles. +A parked worktree whose agent finished would never notify. This design keeps +those side effects alive while parked. + +## Design + +### Park policy (renderer) + +A pure policy module decides which hidden terminal tabs may park: + +- Cold-park hysteresis: a tab must be hidden for 30s before parking. +- Hot-retain working set: recently visible worktrees/tabs are retained + (5 minutes, bounded count) so quick tab switches never pay a re-hydrate. +- Eligibility excludes: visible panes, hidden-measuring startup probes, + activity-portal panes, tabs with pending startup commands or pending + activation spawns, floating-panel tabs, and any tab whose PTY is not + snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). +- Kill switch: `settings.terminalHiddenViewParking === false` disables parking + entirely. + +### Park mechanics + +Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer +renders null for parked tabs). This is the same teardown that tab-group moves +already exercise: transports detach but the PTY session, daemon model, and tab +state all survive. The xterm instance, its buffers, DOM, and WebGL/addon +resources are released. + +### Parked watcher (the piece the reverted attempt lacked) + +While a tab is parked, a pane-less watcher +(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its +consumption mode is decided once at watcher start: + +- **Main side-effect authority on (default):** the watcher is purely + fact-driven — it registers exactly one `pty:sideEffect` fact consumer and + parses no bytes. Titles, agent working/idle/exited transitions, BEL + attention, and PR links arrive as main-tracker facts and drive the same + policy callbacks a mounted pane uses. With the hidden-delivery gate also on, + the watcher marks the PTY hidden so main stops renderer byte delivery + entirely; the DECSET 2031 color-scheme subscribe arrives as main's + `2031-subscribe` fact and the watcher replies out-of-band via + `transport.sendInput`. +- **Kill switch off:** the watcher subscribes to raw bytes through the + dispatcher sidecar mechanism (the same mechanism background agent launches + use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 + titles (all-titles ordering, live-path normalization), the title-transition + agent tracker (completion notification, prompt-cache timer), the OSC-aware + stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 + byte responder (`parked-terminal-mode2031-responder.ts`, whose + `subscribeToPtyData` registration doubles as the delivery-interest signal). + +The two modes drive one shared policy-callback block, so flipping the kill +switch never changes notification semantics. Main's synthetic +agent-title/permission frames feed the main tracker directly and arrive as +facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off +mode. + +Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside +hidden-dropped chunks are answered by main's model responder +([`terminal-query-authority.md`](./terminal-query-authority.md)); in +kill-switch-off byte mode only the 2031 reply is answered and Command Code +output is not scraped, matching the pre-gate status quo. + +### Reveal + +Revealing a parked tab remounts the pane subtree and rides the existing +reattach path: fresh xterm via `openTerminal` (unicode provider activation +before any write), daemon model snapshot > relay replay > cold restore +precedence, replay-guarded so snapshot-embedded queries never answer, then +`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is +disposed before the pane handlers re-register. + +## Invariants + +1. PTY reads never stop; parking only changes renderer-side view lifetime. +2. Bell, agent-completion, title, and PR-link side effects keep working while + parked (watcher parity tests). +3. Reveal shows model-correct output (visual gates: hidden TUI restore, long + table, rendering golden) and accepts input immediately. +4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers + or strand parked state. +5. Memory: parked tabs hold no xterm buffers; renderer memory scales with + visible panes. + +## Relation to later phases (all shipped) + +Side-effect authority in main (Phase 3) replaced the watcher's byte parsing +with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) +stops hidden byte delivery in main, moving the parked 2031 reply from the +byte sidecar to the `2031-subscribe` fact; the model query responder +(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser +mode survives only behind the kill switches. Parking still excludes +remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher +would return as a byte parser only if remote-runtime tabs — whose bytes never +transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md new file mode 100644 index 00000000000..79fb55575f3 --- /dev/null +++ b/docs/reference/terminal-model-view-contract.md @@ -0,0 +1,218 @@ +# Terminal Model/View Contract + +## Goal + +Terminal output should have one authoritative model path and many disposable +views. A renderer xterm is the fast interactive view, but it must not be the +only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. + +This contract defines the boundary the shipped terminal stack implements — and +that future terminal work must preserve — without changing the query-response +behavior that real shells and TUIs depend on. See [Architecture +Status](#architecture-status) for the shipped phases. + +## Terms + +- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, + or remote runtime PTY. +- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this + is mostly the headless emulator plus retained read transcript state. +- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop + subscriber, or CLI read page consuming model state and live output. +- **Snapshot:** A bounded model serialization that can restore a view without + replaying an unbounded byte log. +- **Transcript:** The retained output contract for `orca terminal read`; it is + line/cursor oriented and distinct from a screen snapshot. + +## Non-Negotiable Invariants + +1. PTY reads do not stop to protect renderer performance. Backpressure may bound + delivery to views, but terminal state, notifications, titles, and agent + status keep advancing from the PTY stream. +2. Active visible terminal input/output stays on the lowest-latency path. Bulk + hidden or background output must not delay keystroke-sized foreground redraws. +3. Hidden views do not own unbounded output memory. Main's hidden-delivery + gate drops renderer-bound bytes for hidden-marked PTYs after model + ingestion and emits an out-of-band restore marker + (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. + With the gate's kill switches off, hidden bytes ride a bounded renderer + queue whose overflow latches the same model restore. +4. Returning to a hidden or slept terminal must show model-correct output. A + stale or replaced view may be cleared and replayed from a snapshot, but it + must not show a warning fallback when model recovery is available. +5. Snapshots and live bytes have ordering metadata. A view restore must not + duplicate bytes already included in the snapshot or drop bytes that arrived + after it. Main buffer snapshots report the pending-delivery start sequence + (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a + restore without misreading foreign sequence domains as duplicates. +6. Terminal query authority is singular and structural: the party that + writes a chunk into a live terminal answers its queries. Visible renderer + and remote views keep xterm authority. Chunks dropped by the + hidden-delivery gate are answered exactly once by the main model + responder, from runtime-emulator state plus renderer-pushed view + attributes. Replayed, seeded, or snapshot bytes are answered by no one. + The daemon emulator never answers. (Amended by Phase 5 — see + [`terminal-query-authority.md`](./terminal-query-authority.md).) +7. The transcript contract stays separate from screen restore. `orca terminal + read` must preserve bounded previews, cursor pagination, partial-line rules, + truncation flags, and total counts even if view snapshots change shape. +8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy + the same model/view contract or explicitly report that model recovery is + unavailable. + +## Current Owners + +| Responsibility | Current owner | +| --- | --- | +| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | +| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | +| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | +| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | +| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | +| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | +| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | +| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | +| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | +| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | + +## Snapshot Contract + +A model snapshot must include: + +- terminal dimensions used to produce the snapshot; +- enough ANSI state to rehydrate xterm before snapshot content; +- bounded screen and scrollback content; +- title and cwd metadata when known; +- source metadata that distinguishes headless/model snapshots from renderer + fallback snapshots; +- monotonic ordering metadata for live-output reconciliation when available. + +A snapshot must not: + +- include unbounded transcript history; +- answer terminal queries while replaying into the model; +- overwrite newer live view output with older model output; +- hide that recovery was unavailable for a PTY surface. + +## View Contract + +A renderer or remote view may: + +- write active visible output immediately; +- budget visible inactive output; +- stop receiving hidden output entirely while main's hidden-delivery gate owns + the bytes (model restore on reveal); +- request fresh snapshots for restore, mobile subscription, or explicit remote + snapshot recovery. + +A view must: + +- keep live-output buffers bounded while a snapshot is in flight; +- apply generation or sequence checks before replaying a snapshot; +- refresh/repaint after replay when xterm/WebGL needs an explicit paint; +- keep side effects such as title, bell, cwd, and agent status flowing from the + PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte + delivery is budgeted, gated, or parked. + +## Transcript Contract + +The retained read transcript is not a screen dump. It must preserve: + +- uncursored bounded latest preview behavior; +- cursor reads over completed retained lines; +- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; +- partial-line duplication rules; +- `truncated`, `limited`, and total count metadata; +- bounded memory for long partial lines and large output bursts. + +Snapshot optimizations must be tested against this transcript contract instead +of assuming xterm scrollback serialization can replace it. + +## Required Contract Tests + +Before moving more runtime behavior behind the model/view boundary, add or +extend tests that prove: + +- headless snapshots rehydrate rich alternate-screen TUI state; +- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive + queries (the `session.test.ts` pins are permanent); +- the main runtime responder answers queries only from live chunks the + hidden-delivery gate dropped — never delivered, replayed, seeded, or + remote-subscribed chunks; +- hidden renderer overflow restores from model state without duplicate live + output; +- sleep/wake and worktree revisit restore from model-correct state; +- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; +- remote runtime multiplex output remains ACK bounded and can request recovery + snapshots; +- mobile subscribers receive bounded snapshots without unbounded pending live + output; +- retained terminal reads remain pageable and bounded after large output. + +Current coverage is spread across: + +- `src/main/daemon/headless-emulator.test.ts` +- `src/main/daemon/session.test.ts` +- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, + `pendingDeliveryStartSeq`) +- `src/main/ipc/pty-hidden-delivery-gate.test.ts` +- `src/main/runtime/mobile-subscribe-integration.test.ts` +- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` +- `src/main/runtime/rpc/terminal-multiplex.test.ts` +- `src/main/runtime/orca-runtime.test.ts` +- `src/main/runtime/terminal-query-responder.test.ts` +- `src/shared/terminal-output-side-effects.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` +- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` +- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` +- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` +- `tests/e2e/terminal-hidden-view-parking.spec.ts` +- `tests/e2e/terminal-parked-memory.spec.ts` +- `tests/e2e/terminal-sleep-wake-restore.spec.ts` +- `tests/e2e/terminal-output-scheduler.spec.ts` +- `tests/e2e/artificial-opencode-terminal-load.spec.ts` + +## Architecture Status + +All six phases of the terminal model/view architecture are shipped; the kill +switches noted in parentheses default on: + +1. **Hidden view parking** — "Park hidden terminal views behind a byte + watcher": hidden terminal tabs unmount their xterm after a cold-park + hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects + alive while parked (`terminalHiddenViewParking`). See + [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). +2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": + renderer heap and live-terminal counts gate parking in the perf suite + (`tests/e2e/terminal-parked-memory.spec.ts`). +3. **Side-effect authority in main** — "Track terminal titles in main with + all-titles ordering", "Move terminal side-effect authority to a main facts + channel", "Complete terminal side-effect facts coverage", "Finish terminal + side-effect authority migration": every local/daemon/SSH PTY byte is + side-effect-parsed once in main and delivered as `pty:sideEffect` facts + (`terminalMainSideEffectAuthority`). See + [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). +4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": + main drops renderer-bound bytes for hidden-marked PTYs after model + ingestion; delivery-interest registrations exempt sidecar byte consumers, + out-of-band restore markers latch model restore, and + `pendingDeliveryStartSeq` reconciles live output racing a restore + (`terminalHiddenDeliveryGate`). +5. **Model query authority** — "Answer hidden terminal queries from the + model", "Bridge renderer view attributes to the model responder", "Align + query authority contract and spawn-time ownership": hidden-dropped queries + are answered by the runtime emulator plus renderer-pushed view attributes, + and hidden-at-spawn PTYs are marked before byte one + (`terminalModelQueryAuthority`). See + [`terminal-query-authority.md`](./terminal-query-authority.md). +6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the + renderer's per-chunk hidden-skip eligibility grammar and the 10s codex + startup query window are deleted; the kill-switch-off fallback is the + bounded background queue with overflow-latched model restore. + +Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a +local repaint quirk. Renderer fallback paths retire only when their kill +switches do, and only after the equivalent model path has platform and TUI +golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md new file mode 100644 index 00000000000..e05b5768836 --- /dev/null +++ b/docs/reference/terminal-query-authority.md @@ -0,0 +1,326 @@ +# Terminal Query Authority + +Status: Shipped — Phase 5 of the terminal model/view architecture, kill +switch `terminalModelQueryAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this +phase **amends invariant 6**), +[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) +(Phase 3), and the Phase-4 hidden-delivery gate +(`src/main/ipc/pty-hidden-delivery-gate.ts`). + +## Problem + +Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion +(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`). +Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks +waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang +TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 +hidden skip latch had the same hole (only mode 2031 and the 10s codex startup +window answered), so this is not a regression — it is the long-standing gap +this phase closes. + +Contract invariant 6 ("the model must never answer queries") was written +against a real bug: the daemon emulator replying ahead of the renderer with +default-xterm values (the OSC-11 default-black-background race, +`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger +was never "the model answers" — it was **two answerers for the same bytes**, +one of them with wrong values. Phase 5 keeps the singularity and fixes the +values. + +## Decision: the delivery decision is the reply decision + +Main answers a query **iff main dropped the chunk that carried it**. The same +per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that +decides renderer delivery decides reply ownership, evaluated once, +synchronously, at ingestion: + +- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via + `Terminal.onData` → `transport.sendInput`, unchanged. +- Hidden-marked, no delivery interest → chunk dropped → main answers from the + runtime headless emulator, via the provider input path (`provider.write`, + same path as `pty:write`; daemon shell-ready write gating and the SSH relay + write apply unchanged). +- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both + sides). + +This is structurally exactly-one-responder: a chunk is delivered or dropped, +never both, and each side only answers bytes it actually parsed live. The +mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist +from Phase 4 and are reused, not duplicated. + +Rejected alternatives: + +- **Fact-based renderer replies per query class** (the mode-2031 pattern + generalized): needs a main-side detection grammar per query, a fact round + trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the + emulator is the only state for a hidden pane. The 2031 fact stays because it + is subscription registration, not a state query. +- **Emulator always answers**: re-creates the OSC-11 double-reply race for + visible panes. Never. + +## Mechanism: forwarded emulator onData, not a new grammar + +`HeadlessEmulator` has `onData` wiring behind a per-write capture flag. +For static and model-state queries, xterm core **is** the query grammar: the +runtime emulator runs the same xterm version with equivalent options as the +renderer pane, so main's reply set equals the visible renderer's by +construction — verified empirically against the bundled headless build: +DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including +DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS +(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching +visible behavior today. The headless build has **no theme service**: OSC +4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option +set, so the view-attribute class is answered by responder-registered parser +handlers instead (below) — never by core defaults. + +Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and +attached to the emulator `writeChain` link (the mark can flip between +ingestion and an async write; the decision must not be re-read at reply time): + +1. gate enabled (`terminalMainSideEffectAuthority` and + `terminalHiddenDeliveryGate` both on) AND new kill switch + `terminalModelQueryAuthority !== false`; +2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` + — same module state, same tick as the drop sites); +3. the write is live PTY data — never `seedHeadlessTerminal`, + `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay + (main-side replay guard, mirror of the renderer's `replay-guard.ts`); +4. no remote view subscriber is attached to the PTY (runtime terminal-RPC + subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop + xterm receiving the multiplexed stream answers with view authority, exactly + like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** + register as view subscribers and suppress, even when the consumer is a + read-only watcher — deliberately conservative, because the stream may feed + an older live xterm view and a withheld reply (the pre-Phase-5 status quo) + is strictly safer than a double reply. Consumers that never register a + stream (CLI `terminal.read`, automation observers) do not suppress — they + also do not answer; that bounded no-reply case matches today's behavior. + +Everything the emulator emits outside a forwarding window is discarded, which +also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes +triggered by option mutations). + +## Reply classes + +| Class | Queries | Answer source | +| --- | --- | --- | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | + +### View-attribute bridge + +Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, +not per-PTY: the composed terminal `ITheme` (from +`applyTerminalAppearance`, `terminal-appearance.ts`), +`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme +mode (`resolveTerminalColorSchemeMode` — the same source as the existing +hidden 2031 reply). Pushed on renderer startup and on every theme/settings +apply. + +Main consumes it two ways: + +- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options + inside the replay guard; xterm core then answers DECRQSS DECSCUSR and + DECRQM 12 with renderer-true values (verified working headless). +- Palette and color-scheme replies come from responder-registered parser + handlers on the emulator (`registerOscHandler` 4/10/11/12, + `registerCsiHandler` for DSR ?996n), because the headless core cannot + answer them. The OSC handlers see SET payloads too, so runtime OSC + 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are + tracked per PTY and layered over the pushed base palette — matching what + the renderer's theme service reports for a visible pane. + +Staleness rules: replies use the last push; a theme flip is stale for at most +one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). +**Before the first push main answers no view-attribute query** — a fabricated +default would resurrect the default-black OSC-11 bug; silence is the +documented hidden status quo. + +### Kitty keyboard flags + +`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching +`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low: +for the write-only daemon use, keyboard state never alters serialization; the +change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring +them, and lets the responder answer `CSI ? u` with the flags the hidden app +actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` +for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty +flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty +reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) +stays authoritative. Slice 3 wires the re-seed consumer: the daemon +warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn +result into `seedHeadlessTerminal`, which applies them to the fresh runtime +emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding +window), so hidden `CSI ? u` reports the flags the hidden app actually +pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer +`?0u`; protocol-conformant programs re-push. + +### ConPTY DA1 variant + +The provider kind is known main-side: mirror `isLocalNativeWindowsPty` +(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon +provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the +emulator parser (the main-side twin of +`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by +the forwarding predicate. The override is installed at emulator creation and +retrofitted when the spawn mark lands (daemon stream data can create the +emulator before the awaited spawn response marks the PTY). ConPTY blocking on +a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is +closed by the slice-3 `initiallyHidden` spawn flag (races section). + +## Suppression: when main never replies + +- Visible or unmarked PTY (chunk was delivered). +- Renderer delivery interest registered (chunk was delivered to a sidecar). +- Remote-runtime (`remote:`) PTYs — never markable + (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. +- Remote view subscriber attached (mobile/web/remote desktop owns replies). +- Seed/hydration/snapshot writes into the emulator, and option pushes. +- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an + independent off switch for the responder alone. +- The **daemon** emulator: never, under any setting. The responder lives in + main's runtime only; `session.test.ts:163-190` stays pinned verbatim. + +## Transition races + +Worst cases, per direction: + +- **visible→hidden**: chunks delivered between the visibility flip and the + mark landing in main are hidden-skipped by the renderer write path without + query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden + skip behavior, bounded by one renderer→main IPC hop. After the mark lands, + main answers everything it drops. +- **hidden→visible**: unmark consumes the drop latch and emits the restore + marker; the snapshot replay is replay-guarded, so queries main already + answered are never re-answered from the snapshot; post-unmark live chunks + are answered by xterm once (restore-queued live chunks reply late, not + twice). +- **Split queries across the drop/deliver boundary**: neither parser saw the + whole sequence → no reply; the restore marker resets renderer cross-chunk + state and replay hygiene resets the parser. At-most-once holds. + +Safe-side rule per class: duplicates are structurally impossible (one decision +point per chunk); where the race costs anything it costs a missing reply. +That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or +tolerate silence, as they did for every hidden pane before this phase). The +one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible +pane answers it from the renderer xterm. A PTY spawned hidden previously had +no answerer until the renderer's hidden mark landed in main (one IPC hop +after spawn). Slice 3 closes that window with the `initiallyHidden` +spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` +(never for remote-runtime transports), and main marks the PTY hidden before +the first byte — pre-spawn for +daemon-host sessions whose id is minted up front, immediately after +`provider.spawn` resolves otherwise — so the gate and responder own queries +from byte one. The pane's first visibility sync then re-marks or unmarks +through the existing Phase-4 machinery (unmark emits the restore marker for +any spawn-window drops). + +## Invariants + +1. Exactly one party may answer any query, chosen by the chunk's delivery + decision: delivered → the consuming live view's xterm; dropped → main's + model responder; replayed/seeded → no one. The decision is captured once, + synchronously, at ingestion. +2. Main answers only from live PTY bytes parsed by the runtime emulator — + never from snapshot, seed, hydration, or option-push writes. +3. View-attribute answers are renderer-true or absent: no reply is ever + fabricated from emulator defaults (the OSC-11 lesson). +4. The daemon emulator stays write-only; daemon subprocess query writes stay + zero (`session.test.ts` pins are permanent). +5. Reply parity is structural for static and model-state classes: same xterm + core, equivalent options, no hand-rolled grammar — the only overrides are + the documented ConPTY DA1 variant and the view-attribute parser handlers + the headless core cannot serve. +6. Remote views keep view authority; main yields whenever a remote view + subscriber is attached. + +**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is +replaced by: + +> 6. Terminal query authority is singular and structural: the party that +> writes a chunk into a live terminal answers its queries. Visible renderer +> and remote views keep xterm authority. Chunks dropped by the +> hidden-delivery gate are answered exactly once by the main model +> responder, from runtime-emulator state plus renderer-pushed view +> attributes. Replayed, seeded, or snapshot bytes are answered by no one. +> The daemon emulator never answers. + +The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, +or theme-sensitive queries" splits into: daemon emulator never answers +(unchanged pins) / runtime responder answers only hidden-dropped chunks. The +side-effect authority matrix row "DECSET 2031 reply — query authority stays +with the view (contract invariant 6)" gains a pointer here; its reply path is +otherwise untouched in this phase. + +## Test strategy + +- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs + interest-suppressed; each reply class; seed/hydrate silence; remote- + subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip + between ingestion and async emulator write (captured decision wins). +- Parity harness: shared query byte fixtures through a renderer-configured + xterm (onData capture) and through the responder; assert byte-identical + replies for static + model-state classes, and for view-attribute classes + after an attribute push. +- `session.test.ts:163-190`: assertions stay; the comment is updated to name + the main responder (not "the renderer") as the hidden answerer. +- E2E: hidden `claude /theme` reports the configured theme; hidden TUI + blocked on CPR/DA unblocks while gated; reveal shows no stray reply + fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and + `terminal-hidden-view-parking.spec.ts` stay green. + +## Cut-offs (shipped as three stacked slices) + +1. **Responder core.** Emulator onData wiring + per-write capture + main + replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); + static + model-state classes; ConPTY DA1 override; remote-subscriber + suppression; `terminalModelQueryAuthority` switch; unit + parity tests. + Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. +2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor + option application under the guard, responder OSC/DSR parser handlers with + per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. +3. **Contract alignment.** Invariant-6 amendment in the contract doc, test + bullet split, `session.test.ts` comment, side-effect matrix pointer, and + the Phase 6 prerequisites below recorded as accepted. + +## Phase 6 (delete skip grammar + startup window): prerequisites from this design + +Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s +codex startup renderer-query window are deleted. Kill-switch-off hidden panes +fall back to the pre-grammar path — hidden bytes ride the bounded background +scheduler queue; overflow latches the model-snapshot restore — and never run +a per-chunk content scan. + +Accepted and shipped in slice 3 (except where noted): + +- **Mark-before-first-byte** (shipped): panes spawned without a visible view + are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` + (spawn-record flag, not a renderer round trip) so startup queries — + including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 + removed the codex exclusion with the window: codex spawns are main-owned + from byte zero too, the responder answering their startup probes. +- **Attributes before spawn** (shipped): the renderer pushes composed view + attributes once at app start (right after settings load, before terminal + reconnect/spawn), so spawn-time view-attribute queries no longer fall into + the silent-until-push rule. Per-pane appearance applies keep re-publishing + through the same deduped publisher. +- **Daemon shell-ready write gating** (verified): responder replies through + `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never + dropped, and the queue flushes at the shell-ready marker or the 15s + `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed + with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches + as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains + worth adding. +- With the skip grammar deleted, every chunk is either written to a live + xterm or dropped — the delivered-but-skipped no-reply gap disappears and + the only remaining loss window is the mark IPC race. +- **2031 consolidation** (optional follow-up): move the subscription registry + into the responder (the headless core cannot serve 997 pushes any more than + it can ?996n) and push 997 flips from the attribute cache, retiring the + `2031-subscribe` fact reply, the parked responder, and the parked-tab + theme-flip gap. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md new file mode 100644 index 00000000000..852a607d5bf --- /dev/null +++ b/docs/reference/terminal-side-effect-authority.md @@ -0,0 +1,252 @@ +# Terminal Side-Effect Authority + +Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch +`terminalMainSideEffectAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). + +## Problem + +Main parses every local/daemon/SSH PTY byte before renderer delivery +(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: +side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL +watchers; SSH feeds the same path from `wireUpPtyEvents` in +`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects +users see — bell unread/notifications, title transitions, agent-complete +notifications, command lifecycle, PR links — were derived a second time by +renderer byte parsers. That duplication forced Phase 1's watcher to parse +bytes, forced main to fabricate synthetic OSC title frames over `pty:data` +just so renderer parsers could see them, and blocked Phase 4 from ever +stopping hidden byte delivery. Phase 3 made main the side-effect parser for +every PTY whose bytes transit local main; the renderer byte parsers +(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte +mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. + +## Authority Matrix + +"Main" means parsed once in `onPtyData` and delivered as derived facts. +Remote-runtime PTYs (`remote:`) never transit local main; the renderer +(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. + +| Side effect | local-daemon | SSH | remote-runtime | +| --- | --- | --- | --- | +| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | +| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | +| BEL attention (OSC-aware stateful detector) | main | main | renderer | +| OSC 133;D command-finished exit code | main | main | renderer | +| GitHub PR-link scan | main | main | renderer | +| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | +| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | +| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | + +## Main-Side Tracker + +- The side-effect core shared with the renderer processor lives in + `src/shared/terminal-output-side-effects.ts`: all-titles ordering via + `extractAllOscTitles` (coalesced working→idle transitions are why last-title + is insufficient — issue #1083), `normalizeTerminalTitle`, the literal + `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, + `src/shared/agent-detection.ts`), the `createAgentStatusTracker` + transitions, the stale-working-title 3s timer + (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector + (`src/shared/terminal-bell-detector.ts`). +- One tracker per PTY on `OrcaRuntimeService`, lazily created like + `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the + stale-title timer). +- It replaced the chunk-level last-title extraction in `onPtyData`: titles + feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, + and pending-message delivery see intermediate transitions instead of only + the chunk's last title. PTY/leaf records keep the **raw** last title + (worktree `ps` and mobile tab titles expect raw); emitted facts carry + `(normalizedTitle, rawTitle)` like `onTitleChange`. +- No deferred drain in main — the renderer's setTimeout(0) batching + (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which + does not exist in main. Main applies synchronously and batches the IPC per + flush. +- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own + last-title scan, untouched: synthetic titles must never reach it. + +## Event Transport: `pty:sideEffect` + +One batched main→renderer channel (`window.api.pty.onSideEffect`, +`src/preload/index.ts`). It is **not** routed through the pty dispatcher: +the renderer fact-consumer registry +(`terminal-side-effect-facts-handler.ts`) subscribes directly via +`window.api.pty.onSideEffect` — one channel subscription per renderer, with +exactly one registered fact consumer per PTY. Events are **facts, not +decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), +`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries +`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf +records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY +`outputSequence`. + +Ordering rules: + +1. Per-PTY in-order; facts from one chunk are emitted in byte order (status + payloads, then titles in sequence, then bell — the renderer drain's order). +2. Deliberately **not** synchronized with `pty:data`: side effects must keep + advancing while renderer delivery is ACK-gated (contract invariant 1). A + completion title may reach the store before the visible xterm paints the + final output; that is acceptable — attention/title state is out-of-band UI + state, and today's renderer drain already decouples by many batches under + timer throttling. +3. No attention replay: facts emitted while no renderer is subscribed are + dropped. On transport attach/park-handoff the renderer pulls a title-only + snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces + the eager-buffer behavior where replay restores titles but is barred from + bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The + store handler ignores a replay title older (by `outputSequence`) than the + last live title fact it applied. + +## Renderer Store Handler (policy stays in the renderer) + +Notification semantics, all preserved across the authority flip: + +- BEL marks worktree+tab unread unconditionally — including the focused pane + (`onBell`, `pty-connection.ts`); pane unread only behind + `experimentalTerminalAttention`; keydown clears unread + (`onTerminalKeyDown`, `pty-connection.ts`). +- BEL's OS notification is delayed 250 ms and yields to a pending + agent-task-complete (`scheduleTerminalBellNotification`, + `pty-connection.ts`). +- working→idle starts the Claude cache timer (null settings = not hydrated, + treat enabled) and schedules completion with 250 ms grace + 1500 ms max + wait + detail-wait store subscription + (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / + `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, + `agent-task-complete-policy.ts`). +- Completion unread is suppressed only for the exact visible foreground pane + (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread + has no such check. +- Dispatch-time liveness/staleness guards + (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's + 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, + `src/main/ipc/notifications.ts`) remain the final gates. + +These need live renderer store state (PTY/layout maps, pane visibility, +settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: +the pane-independent per-paneKey handler module +(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and +subsumes both `pty-connection.ts`'s callbacks and the parked watcher's +callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — +one policy path whether the tab is mounted, hidden, or parked. Main holds +**no** notification timers; only the stale-title timer (parser state) lives +in main. + +## Synthetic Frame Reroute + +`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, +`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, +so synthetic agent-title/BEL frames enter the per-PTY tracker directly — +**not** `onPtyData`, so emulator state, tails, transcripts, and stats never +see them. The decorative-frame visibility gating +(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` +copy survives only in kill-switch-off mode, where renderer parsers still +need the bytes. The visible xterm renders nothing from titles, but +`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` +(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile +parity); main prefers its own tracked title over renderer snapshot +`lastTitle` in both serialize paths. Under main authority synthetic frames +no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, +`pty-dispatcher.ts`). + +## Migration Switch and Double-Fire Prevention + +Authority is structural per PTY kind — the predicate is "bytes transit local +main", exactly the `shouldOwnAgentStatusInRenderer` split +(`pty-connection.ts`). One renderer-consulted kill switch +(`settings.terminalMainSideEffectAuthority`, default on, mirroring +`terminalHiddenViewParking`): when on, IPC transports and the parked watcher +do not register byte parsers for local/SSH and the store handler consumes +`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` +events are ignored. Main always parses and emits (its internal consumers need +the tracker regardless); main consults the same setting only to keep the +legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly +one consumer per fact at any time — decided at transport/watcher creation, so +no per-chunk race. + +## Sidecar Consumers and Phase 4 + +Keep renderer byte access (input pacing / raw-output consumers, not side +effects): `agent-paste-draft.ts` (DECSET 2004 readiness), +`launch-agent-background-session.ts` (startup-injection pacing, onData +passthrough), `automation-session-observer.ts` (onData passthrough), and +`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for +parked tabs while the delivery gate is off). Their duplicated local OSC 9999 +store writes are gated off under main authority (the `onAgentStatus` +automation callbacks still fire; only the racing `setAgentStatus` store +writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active +`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a +ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main +authoritative, the parked watcher is purely fact-driven: byte parsing exists +only in kill-switch-off mode, and the 2031 reply comes from the +`2031-subscribe` fact when the gate is on (the byte responder sidecar only +when it is off). The watcher file is deleted outright only when the kill +switch retires — it returns as a byte parser only if remote-runtime tabs +ever become parkable. + +## Invariants + +1. Every byte is side-effect-parsed exactly once, by exactly one authority, + chosen structurally per PTY kind. +2. Attention facts never replay: snapshot/eager/attach replays restore title + state only. +3. Notification policy (grace timers, yielding, suppression, dispatch guards) + lives with the renderer store; main emits facts with ordering metadata. +4. Side-effect facts keep flowing while renderer byte delivery is + backpressured, parked, or stopped by the hidden-delivery gate. +5. Synthetic agent frames feed the model tracker, never the emulator, tails, + transcripts, or stats. + +## Test Strategy + +- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte + fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning + OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under + fake timers, OSC 133;D, split PR URLs) run through the renderer + `createPtyOutputProcessor` and the main tracker; assert identical ordered + fact sequences. +- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle + parity, tui-idle waiter transitions, synthetic ingestion); store-handler + tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. +- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, + `parked-terminal-byte-watcher.test.ts` (retires with the watcher); + `pty-transport*.test.ts` stay (processor remains for remote + kill switch). +- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, + `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, + `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases + (parked tab, focused-pane suppression, kill switch off). SSH parity is + exercised manually per the SSH test procedure before each slice ships. + +## Cut-Offs (shipped as four stacked slices) + +1. **Shared tracker in main.** Extract the processor core to shared, run the + per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity + tests. Main-internal consumers only; no IPC or renderer change. +2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, + titles/bell/tracker authority to main for local+SSH behind the kill + switch; parked watcher stops byte parsing for those kinds. +3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; + OSC 133;D and PR-link facts; mobile `lastTitle` source preference. +4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked + watcher shrunk to fact-driven mode (deletion waits on kill-switch + retirement), Phase 4 delivery-interest registration documented in the gate + design. + +## Open Items + +- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic + checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon + `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is + derived from real PTY bytes only — synthetic hook title frames never reach + the daemon process, so that field cannot carry hook-driven titles. Today no + restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots + surface only the ANSI payload), so there is nothing to fix. Main-side + consumers of the renderer serializer's `lastTitle` (mobile snapshot reads + and the headless hydration seed) prefer main's tracked title. If a future + consumer starts reading checkpoint `lastTitle`, it must route through the + same tracked-title preference. +- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is + removed, the parked watcher's byte-parser mode, the renderer transport + parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all + become dead code and the watcher byte path can be deleted outright. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index a9c3165e7cf..e3e7a125988 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -1,5 +1,9 @@ # Terminal Main-Owned State +This document covers the hidden-output recovery slice. The broader terminal +model/view boundary is defined in +[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). + ## Problem Hidden and background terminal panes cannot rely on renderer memory as the only @@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated and can serialize it. +Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default +on — see the contract's Architecture Status), main drops hidden renderer-bound +bytes after model ingestion and emits an out-of-band restore marker, so a +gated hidden pane accumulates no renderer backlog at all. The overflow path +below is the fallback for kill-switch-off mode and for hidden PTYs with an +active delivery-interest sidecar. + The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: 1. The scheduler replaces the queued backlog with a small warning fallback. diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md new file mode 100644 index 00000000000..dde77ac2450 --- /dev/null +++ b/notes/garble-fuzz-divergences.md @@ -0,0 +1,296 @@ +# Garble differential fuzz — divergence log + +Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz +(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is +a case where restoring a hidden terminal from its main-side snapshot +(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal) +produces a screen that differs from an always-visible renderer terminal fed the +same bytes. Any such diff is a user-visible garble on reveal. + +## Method + +- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane + sizes, PTY-style random chunk splitting. +- Differential: production `HeadlessEmulator` snapshot replayed into a fresh + renderer-parity terminal, compared cell-by-cell (text, per-cell style, + cursor, modes, scrollback) against an always-visible renderer-parity twin. +- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's + buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`, + `vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified + against `buildDefaultTerminalOptions` in + `src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and + `pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`, + `drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored + cell attributes, so their omission is not a source of false diffs. + `windowsMode` is unset in both (matches renderer). Addon versions: + `@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all + `*-beta.287` (headless `6.1.0-beta.287`). +- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug + (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is + listed below. + +## Inventory + +| bug | found by | seeds | classification | +| --- | --- | --- | --- | +| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN | +| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) | +| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) | +| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) | +| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) | + +Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and +their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D +carries position only (saved SGR/charset are not re-established — the synthetic +ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate +snapshot field written LAST by restorers because any later ESC (e.g. the +post-replay reset) would abort the dangling sequence; its bytes are already +counted by the snapshot seq, so tail-slice arithmetic is unchanged. + +All five bug classes are reproduced by dedicated minimal `test.skip` repros so +they cannot silently regress, AND each is tolerated + counted by its suite's +corpus loop so deep mode surfaces only genuinely NEW divergences: + +- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via + `snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized + snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a + full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`. +- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the + Bug-C cursor cascade are kept out of the corpus by an append-only racing tail + (no DECSC/cursor motion) and pinned only as standalone repros. Green at the + default 200 and at `FUZZ_ITERATIONS=1000`. + +Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts +tripping on most seeds fails the suite instead of silently swallowing it. + +Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored +text mid-line") does not diverge on the current harness. It is a `savedCursor +Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the +emulator and the serializer here. It was most likely an earlier observation +folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a +transient during harness construction. No live divergence at 113. + +--- + +## Bug A — SerializeAddon drops null cells at a soft-wrap boundary + +**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found +and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz +suite (V1 seed 31, V2 seed 157). Full mechanism documented in +`bufferHasSerializeHostileWrappedRow` and the suite's headline comment. + +- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell + passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps + at the right margin, overwriting the previous row's last cell and shifting the + tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`. +- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null + takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so + the ECH erase lands one cell right and the first filler `-` survives. + +**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake, +mobile subscribe replay) paints lost/shifted characters or stray `-` fillers +when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite; +unskip the repros when upstream fixes or a local serialize post-processor lands. + +--- + +## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops). + +**Minimal repro (isolated, no fuzz corpus needed), cols=20:** + +``` +live bytes: "\x1b[2mA\x1b[22m\x1b[1mB" +SerializeAddon → : "\x1b[2mA\x1b[1;22mB" +live cell B: bold=1 dim=0 (style flags 100000) +restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST +``` + +**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen +from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in +xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the +emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is +neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`. +(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is +specific to a dim-cell → bold-only-cell attribute transition.) + +**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a +bold status/spinner line (Claude Code, Codex). On the live screen the status +line is bold; after a hide→reveal snapshot restore it renders normal-weight. +The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`. +Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`. + +--- + +## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK). + +**Minimal repro (isolated, pure serializer replay), cols=10:** + +``` +live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5) +SerializeAddon → : "0123456789\x1b[2B\x1b[6D" +live cursor: { x: 4, y: 2 } +restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT +``` + +Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"` +and round-trips the cursor exactly, isolating the trigger to a full-width final +content row. + +**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in +the *wrap-pending* state (cursor visually on the last column, logically "one +past"). The serializer computes its final cursor-restore as relative +`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed +one column short, so the restored cursor lands at `x-1`. Reproduced with pure +`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble +or normalization involved**, confirming it is upstream, not Orca's snapshot +path. + +**Why it garbles a real pane:** the cursor is where the next keystroke echoes +and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line +reached the right edge (wide status lines, long prompts), the cursor sits one +cell left of where the live pane had it — visible as a mispositioned prompt +caret or spinner, and subsequent input can overwrite the wrong cell. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('restores the cursor exactly when the last content row fills the right margin')`. +Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`. + +--- + +## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity +fuzz. + +**Minimal repro (cols=20):** + +``` +hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2, + move to r3c9, write CD) +tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X) + +live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 } +reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 } + ^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2 +snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all) +``` + +**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few +rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` / +`CSI u`) is runtime state that never appears in the serialized buffer, so it +cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before +the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or +`\x1b[u`), the restore targets the fresh terminal's default saved position +(home) instead of where the TUI saved it — the next writes land at the wrong +cell and overwrite live content. + +**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and +status-line redraws (save cursor, jump to a corner to paint a clock/token +counter, restore). If the save happens while the pane is hidden and the restore +fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2 +seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and +whose `\x1b8` fell in the racing tail after chunk-splitting). + +**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor +motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is +tested in isolation from this and the other terminal-state-loss garbles. Bug D +is instead pinned as a standalone repro, +`hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`. + +**Fix (applied):** the snapshot epilogue re-establishes the register with +`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`, +reading the active buffer's core register via `readSavedCursorRegister` +(alt screen yields its own register). Position-only: saved SGR/charset are not +carried. + +--- + +## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2). + +**Minimal repro (cols=20):** + +``` +hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet) +tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD) + +live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic) +reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character +snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone) +``` + +**Mechanism:** a PTY read (one delivery record) can split an escape sequence. +If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the +serialized SCREEN cannot carry the partial sequence (it lives in the parser +state machine, not the buffer). The racing tail supplies the sequence's +remaining bytes, but with the prefix gone the terminal parses them as literal +text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`. + +**Why it garbles a real pane:** any TUI whose output is heavy with escape +sequences (all of them) can have a read boundary fall mid-escape; if a reveal +lands in that window the continuation renders as stray literal bytes (a rogue +`m`, `H`, digits) injected into the visible text. + +**Reachability:** requires the reveal/snapshot to fire in the gap between the two +halves of a split escape. `main` writes each PTY read to the emulator and +records it as one delivery unit (`session.ts emitSubprocessOutput`), and the +snapshot is taken synchronously at a drain — so the window is a single delivered +record that ended mid-escape. Narrow but real. + +**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends +mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the +serialize wrap bug — it fired on ~24% of the corpus, confirming the class is +common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('completes an escape sequence split across the hide/reveal boundary')`. + +**Fix (applied):** the emulator tracks the unparsed trailing partial escape at +ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse +mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers +write it LAST, after their post-replay resets, so the racing tail's +continuation completes it exactly as live. Snapshot seq already counted those +ingested bytes, so reconcile slicing is unchanged. + +--- + +## Known-legitimate normalization (NOT bugs) + +- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link + cells underlined; SerializeAddon never re-emits OSC 8. Production restores the + link ranges out-of-band via `snapshot.oscLinks` + (`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the + underline by design. Pinned by the passing + `it('drops OSC 8 underline from byte replay but preserves the range …')`. +- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette + indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored + cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the + same 16 theme slots — no visual difference. Canonicalized by + `canonicalColorMode` in the parity fixture. + +--- + +## Corpus vs deep mode + +- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default + `FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A, + B, and C are each tolerated + counted by a predicate, so the corpus fails only + on a genuinely new divergence. +- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default + `FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail + is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`). +- Combined default runtime is ~19s (well under the 60s gate). +- `FUZZ_SEED=`: re-run exactly one seed for a repro (both suites). diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md new file mode 100644 index 00000000000..870c3813911 --- /dev/null +++ b/notes/orca-performance-branch-guide.md @@ -0,0 +1,532 @@ +# orca-performance Branch Guide + +Agent-facing map of every optimization on this branch: what it does, why it exists, +where it lives, and the invariants you must not break when adding to it. The +chronological evidence trail (benchmarks, retractions, A/B protocols) is in +`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view. + +**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load +(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s). +As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput +11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms +(10× Terminal.app). + +## The pipeline + +``` +shell → pty → daemon (persistence, headless model) → unix socket + → main (ipc/pty.ts: batching, delivery gate, flow control, snapshots) + → IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm) +``` + +Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer +MessagePort). The daemon owns sessions so they survive app restarts; it also runs +a headless xterm emulator per pty — the _model_ — which is the source of truth +for screen contents. The renderer terminal is a _view_ that can be discarded and +rebuilt from model snapshots. + +## Optimization inventory + +### 1. Renderer parse-path fixes (the original 16× on agent TUIs) + +- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain + cadence follows xterm's actual parse completion instead of fixed timers, so the + queue never outruns the parser. +- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only + re-process a bounded window instead of the full retained tail. Guarded by + differential fuzz `retained-tail-redraw-window.equivalence.test.ts`. +- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent + wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at + 50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's + per-chunk cost. + +### 2. term-speed-2 chain (model/view contract — the architecture) + +Revived from ~38 never-merged branches; kill-switched, default ON. Docs: +`docs/reference/terminal-model-view-contract.md`. + +- **Hidden view parking**: hidden tabs tear down their xterm view entirely + (memory: parked panes cost ~0). +- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are + dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a + model snapshot + live chunks after the snapshot's seq. +- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd) + from the model so parked panes stay live in the UI without a view. +- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC + colors) deterministically from the model for hidden panes. +- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal + reconciliation drops duplicates already covered by the snapshot baseline. + +### 3. Batching & scheduling cadence + +- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts` + (`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`). + At 9% utilization there is no queue — latency was literally the sum of fixed + batch windows. This one change took dev DSR-load 19→8ms. +- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`): + Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks + without the clamp, preserving cooperative yield (input/paint still serviced). + Vitest keeps the timer path (fake timers can't advance channel posts). +- **Input write coalescing** (from main, #7205): renderer input writes coalesce + instead of queuing macrotask-per-keystroke. + +### 4. Backpressure (the correctness spine — read before touching delivery) + +Three cooperating layers, innermost first: + +- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler + `ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the + chunk is legitimately discarded), not when IPC delivered it. + **INVARIANT: every delivered chunk credits exactly once — parsed or + discarded.** Every scheduler/pty-connection discard path (backlog replacement, + disposed terminal, reconcile drop, split remainders) must fire the credit. +- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`, + `applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals + (TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a + fully-gated pty triggers a resync probe instead of a timeout reset. The only + timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight + gate + 2MB pendingData cap sit on top. +- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`, + `pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed + wedge where every main→renderer PUSH channel dies while invoke stays alive + (v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden + resync probe can never reach. The 15s heartbeat costs one Map upsert per + received chunk and does no IPC while output flows; mutation stays + verified-state-only (the timer decides when to REPORT; the write-off derives + entirely from the renderer's cumulative received totals, never wall-clock, + and a received-but-unparsed window is never written off). Heal = re-attach + push listeners + pull restore markers through the modelRestoreNeeded router. + E2e blackhole harness: `__terminalDeliveryWatchdog`, + `terminal-push-delivery-loss-recovery.spec.ts`. +- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts` + and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts): + recovers the field-confirmed wedge where macOS occlusion tracking pins + `document.visibilityState` at `'hidden'` after display sleep and never fires + another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate + dropped across 2 pane-level-visible ptys, transport healthy). Real user + input (keydown/pointerdown/window focus) while the document claims hidden is + a physical contradiction — it latches an override, runs each pane's existing + visibilitychange resync (gate unhide + hidden-output restore), and a genuine + visibilitychange hands authority back. No timers; recovery is purely + event-proven, and the failure bias is safe (a wrong override only restores + pre-gate delivery cost, never drops bytes). Hot-path cost: zero when + visible (same single comparison); one property read per user-interaction + event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the + freeze repro and the keystroke recovery, plus the + `hiddenDeliveryGatedVisiblePtyCount` field discriminator). +- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed + `await window.__orcaTerminalFreezeReport()`): a single DevTools command that + returns renderer state (visibilityState + stale override, pty:data listener + count, watchdog totals), main's snapshot with a per-pty delivery table + (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, + window focus flags, power suspend/resume ages, app version), and bounded + breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared + ring: 100 entries, same-kind coalescing) recording gate marks, visibility + trust changes, watchdog stalls/heals, restore markers, heal write-offs, + renderer lifecycle resets. Pty ids are redacted to their `@@` suffix — + daemon session ids embed worktree paths. Recording happens only on rare + transitions; the table/report is built only when read. This exists so a + field freeze report never needs a follow-up ask. +- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit + guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that + keep a split pane's death near the hidden/park boundary from stranding state + (field incident: a closed setup-split leaf persisted in `root` with no + binding and remounted as a permanently blank pane, unreachable by + dead-session reconcile — it skips ptyId-null panes by design). + (1) The "keep a fresh split whose newborn PTY died" branch is **gated on + `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" + proves nothing there; a hidden newborn death must `closePane`, or the kept + pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** + reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a + mounted TerminalPane), so the sidecar itself collapses the dead leaf out of + the stored layout via `detachTerminalLayoutLeaf` — a stale binding left + behind reattaches on reveal and the daemon re-creates the exited session id + as a fresh shell (silent pane resurrection). E2e: + `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every + lifecycle phase and asserts leaves(root) == bindings == live panes. +- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / + 32KB resume watermarks, keyed off **pendingData only** — never renderer + counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's + buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous + (drops impossible); for daemon ptys the pause notify has ~20-30ms socket + latency, so wire-speed bursts can still cross the 2MB cap (known follow-up: + daemon-side self-pacing watermark). +- **Shallow stream-socket write gate + per-session fairness** + (`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices / + 4KB small-session bypass / 32MB write-through valve; kill switch + `ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for + every session — bytes already written can never be overtaken, so a deep + user-space buffer buries a visible pane's echo behind other panes' bulk + (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at + the gate and hold in the batcher, where the interactive flushSession path + and the deterministic small-session bypass still jump them; socket `drain` + refills. This layer alone bounds echo latency by the shallow depth. +- **Background keep-tail stream thinning + daemon fact authority** + (`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail, + `daemon-background-transient-facts.ts`; kill switch + `ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from + pendingData flow control (main drops their bytes after ingestion), which + let N background agents run unbounded ahead of main. Main mirrors the + hidden-delivery gate to the daemon via the wire-tolerated + `setSessionBackground` notification (introduced in v19; authoritative + thinning requires v20 snapshots, so preserved v19 sessions are explicitly + unthinned; older daemons swallow it) — but a live remote view subscriber + (mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`). + Backgrounded sessions' queued output is keep-tail dropped (oldest bytes + replaced by an in-order `dataGap` event; reply-eliciting query bytes are + salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused, + so reveal stays instant with zero catch-up. Un-background neither discards + nor force-flushes the queued tail — restore paths read MAIN's model + (hidden-output recovery buffer), so a discarded tail loses a finished + program's last output forever (caught by the ACK-backpressure e2e), and a + 16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own + bytes; the ordered drain loop delivers it within the budget below. Two + aggregate bounds make that budget real: (1) a GLOBAL background keep + budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more + backgrounded sessions hold queued data, and tighten retroactively when the + count grows — without this, N sub-cap sessions queue N×cap and a worktree + switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden + restore vs the 1.5s budget, probe-verified drain overlap); (2) a + kernel-flush refill sentinel: a held flush pass arms one ~90B empty data + event whose write callback re-flushes when the kernel accepts the + in-flight bytes — without it, held bulk advances one gate-depth per + 'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy + daemon). NOTE: an empty `socket.write('')`'s callback fires immediately + even with megabytes buffered (verified) — the sentinel must be a real + protocol no-op line. Notifications are + structurally lossless: while backgrounded, the DAEMON runs the same shared + scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133 + command-finished / pr-link / DECSET 2031) over every byte BEFORE drop + decisions and relays facts as in-order `transientFact` events; ordered + `sessionBackgroundMarker` events hand scan authority back and forth + (main suppresses just those four scanners in between), and the emulator's + `partialEscapeTailAnsi` seeds each side's fresh scanner carry so a + sequence split across the handoff neither phantom-fires nor goes missing. + Titles/agent-status stay main-side (they converge from the kept tail and + fuse with synthetic spinner frames). On `dataGap`, main resets its + cross-chunk parse carries, drops the mobile headless mirror (rebuilds from + tail/seeds), and sends the model-restore-needed marker so any + renderer-side buffer heals from the snapshot. Visible ptys are never + touched by this layer. Backlog observability: + `ORCA_DAEMON_STREAM_BACKLOG_FILE=` JSONL + (`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`, + `setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`). + Causation A/B (`bench:multi-workspace-typing`): realistic steady rates + (8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine + do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50 + 29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms. + +### 5. Flood resilience (why bulk output can't wedge or lie anymore) + +- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop + abandons immediately when a foreground pane's live-chunk queue overflows + (3-iteration hard cap), and a 2s flood-suppression window stops main's own + backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming + restore — bytes write through, ONE deferred repaint heals after the flood. + This killed a positive feedback loop (restore starves ACKs → main drops → + drop re-arms restore) that caused multi-second renderer stalls. +- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal + queries are extracted (`terminal-reply-query-extraction.ts`) and answered by + _synthesizing replies on the input path_ (CPR from live buffer, DA1 canned, + OSC via direct responder) — probes and TUIs never hang on a dropped reply. +- Drops are downstream of the model: the daemon ingests every byte, so the + post-flood repaint restores complete, correct content. + +### 6. Wake/sleep recovery + +- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes + WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed). +- Cumulative ACKs make the historical "lost ACKs across suspend pin the global + window forever" wedge (BMW user bug) structurally impossible. + +### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned) + +Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs +found by differential fuzzing; four fixed, one tolerated: + +- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted + `1;22` (22 clears the bold 1 just set). Patched via pnpm patch + (`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the + bold/dim group (+2 sibling bare-22 defects). +- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely: + `serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`) + appends absolute CUP from the source terminal's authoritative cursor + (skipped when wrap-pending, where CUP would corrupt). +- **D: DECSC saved-cursor register not serialized** — snapshot appends + `CUP(saved) + ESC 7 + CUP(actual)` when a register exists. +- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) — + `terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the + unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written + LAST on restore so continuation bytes complete the sequence. Seq accounting + unchanged (the tail is a suffix of bytes ≤ snapshot seq). +- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by + `bufferHasSerializeHostileWrappedRow`, the only remaining tolerance. + +## Correctness infrastructure (run these before merging delivery/restore changes) + +- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs + reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep, + `FUZZ_SEED=n` to replay. +- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random + hide/reveal boundaries × snapshot seq × racing chunks must equal an + always-visible reference. +- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped + = regression alarms). +- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test — + byte-identical vs control), `terminal-parked-memory`, + `terminal-sleep-wake-restore`. +- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests + (pane-manager / terminal-pane suites). + +## Benchmarking protocol (hard-won rules) + +- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under + 1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the + terminal under test. +- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12 +--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label ` + — real keystrokes (CDP) into a visible pane while N hidden-worktree panes + replay paced agent-TUI streams through real daemon ptys; decomposes each key + into input-half (keydown→pty, sidecar timestamps) and echo-half + (pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s: + p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer + timer drift staying ~15ms while echo-half grows means the backlog is + upstream of the renderer (daemon socket / main ingest). +- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev + benches used 3MB and never tripped the cap. +- Load-controlled A/B only: alternate builds within one session; dev carries ~2× + day-to-day variance. Never conclude from runs while agents/builds hammer the + machine (two false convictions came from this). +- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang). +- Packaged builds are truth; dev has ~2× overhead. + +## Release mechanics + +- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance +version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above + its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter + (`release-rc-history.mjs`), telemetry identity classifier, and build guard are + all suffix-aware — a suffixed rc classifies as `rc`. +- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release + (PR #7278; merged here) — perf-line users self-update after one manual install. + +## Syncing with main: MERGE, never rebase + +`orca-performance` is a long-lived, shared, continuously-pushed integration +branch — RCs are cut from it and agents branch off it. **Always +`git merge origin/main`; never rebase** (rebasing rewrites pushed history and +strands every RC tag, fix branch, and worktree based on the old commits). +Conflict pattern, established over ~6 syncs: + +1. **Our structure wins; main's semantics graft in.** This branch deliberately + restructures terminal code (shared scanners, single-policy handlers, + model/view split). When main adds a feature inside code we've restructured, + keep our shape and port their new behavior into it. Example: main inlined an + OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared + `createOsc133CommandFinishedScanner` (main's side-effect tracker must parse + byte-identically) and added 133;C support to the shared scanner instead. +2. Preserve the invariants in **Guardrails** below through every resolution — + especially chunk-credit, `pendingData`-keyed flow control, and the + single `handleCommandFinished` policy (byte path AND sideEffect-fact path + route through it). +3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon + suites, and both fuzz suites. Commit the merge with a message stating what + was kept from each side; push. If the push races a moved remote, merge the + remote tip — never `pull --rebase` a merge. +4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench + before the next RC cut. + +## Known limits / next levers (in rough priority order) + +1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at + wire speed before `pausePty` bites. Fix: daemon enforces its own watermark + locally (VS Code does this server-side for remotes). +2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining + drain cadence dominate the 13.3ms prod p50. +3. utilityProcess router endgame: take main off the per-byte hot path + (VS Code's ptyHost→renderer MessagePort shape). +4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up). +5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control → + term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK) + is open against main separately. + +## Guardrails for future agents + +- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path + that receives, defers, drops, or splits pty data in the renderer, prove it + credits exactly once. The credit-invariant unit tests are the gate. +- Flow control keys off `pendingData` only. Do not couple it to renderer + counters; the two layers compose because they are independent. +- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes + ≤ its seq (Bug E made this true; don't regress it). Chunks after restore are + reconciled by seq — off-by-N re-triggers duplicate-drop garble. +- Hidden panes must receive nothing (delivery gate) but side-effects and query + replies must stay live via the model. If you add a new query type, wire it + through model authority AND the drop-path synthesis. +- Never add timeout-based recovery that mutates counters (user requirement — + design decision from #7260). Deterministic resync or nothing; hygiene timers + may only log. +- Any change on the delivery/restore path: run both fuzz suites, the roundtrip + tests, the chain e2e trio, AND a 10MB bench before calling it done. + +## Audit 1 + +Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main → +renderer terminal path, with particular attention to hidden delivery, parking, +stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach, +mobile/remote composition, SSH routing, and teardown. The audit treated model +state and user scrollback as correctness requirements, not expendable memory. + +### Findings and fixes + +1. **Hidden-gate handoff owners could undo one another.** A parked watcher and + an unmounting/remounting pane shared one boolean hidden mark; likewise a + retiring pane could report `visible=false` after its replacement had already + reported `visible=true`. Hidden claims are now reference-counted and + visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager + pre-mount buffers no longer hold raw-byte delivery interest: they are not a + side-effect consumer, and model-backed hidden output can restore from a + snapshot. This removes the ownership and eager-interest races formerly + listed in Known limits item 6 without weakening parked side effects. The + remaining transient-visibility concern was checked separately: bind and + reconnect reports read `TerminalPane`'s synchronously refreshed + `isVisible && isWorktreeActive` ref, the global effect uses the same + expression, and owner counting prevents a retiring pane from overriding its + replacement. No hidden-worktree `visible=true` report site remains. + +2. **A natural/synthetic daemon exit could overtake final output.** When a + shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event + directly. The final bytes could therefore arrive after `exit`. Exit is now an + ordered control event in `DaemonStreamDataBatcher`; both natural and + synthetic exits flush through the same FIFO. A deep-socket regression test + pins final-data-before-exit ordering. + +3. **Keep-tail thinning could permanently reduce scrollback to the retained + tail.** On `dataGap`, main discarded its headless model, then rebuilt it from + later tail bytes even though the daemon still owned the complete model. Live + daemon snapshots now carry `outputSequence`; the provider exposes an + authoritative `getBufferSnapshot`, accepts the requested scrollback depth, + and main requires that provider snapshot after a gap. Reconciliation starts + in the pre-snapshot absolute sequence domain and runtime sequence accounting + advances across dropped bytes. The daemon remains the source of complete + scrollback instead of making a transport optimization destructive. If that + authoritative RPC is temporarily unavailable, main now returns no snapshot + and lets the renderer retry; it never paints main's known-incomplete tail as + a full recovery. + +4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA + bytes salvaged from a dropped region are copies of bytes already counted by + the daemon, not new output. Stream events now distinguish delivered text + from `sequenceChars`; salvaged query data advances by zero while the gap + advances by the original characters. Main can still parse/deliver the query + copy without shifting every later snapshot baseline. + +5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK + credit fired when bytes entered `terminal.write`, before xterm's callback. + Split scheduler chunks also attached `onParsed` to the first slice. ACK + credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the + final xterm parse callback, and is released exactly once on throw, discard, + or terminal disposal. Submitted-but-unparsed credit is retained until parse + or disposal, so main's flow-control window measures parser work rather than + renderer submission. + +6. **Hidden restore ignored configured scrollback.** The renderer always asked + for 5,000 rows, so users configured for 10k–50k silently lost older history + on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback + option and clamps it through the shared 0–50,000 policy + (`terminal-hidden-restore-scrollback.ts`). + +7. **Active alternate-screen snapshots discarded the normal shell buffer.** + SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old + normalization sliced away everything before the last `?1049h`. A restored + TUI looked correct until it exited alternate mode, then returned to empty + history. Snapshots now carry the normal buffer separately in + `scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose + both buffers; an already-alt renderer exits alt, clears/rebuilds the normal + buffer, then re-enters and rebuilds alt. History replay also composes both + buffers, including legacy empty-field compatibility. + + Deep fuzz then found a second two-buffer issue: normal-buffer serialization + can leave its SGR pen active while the separately serialized alternate body + assumes default SGR. The rehydrate boundary now emits `SGR 0` before + `?1049h`, preventing a shell color from tinting restored TUI cells. The + regression proves the TUI is visible immediately and `?1049l` returns to the + original shell history. + +8. **Daemon provider wrappers forwarded only part of the recovery contract.** + A preserved current/legacy daemon could emit `dataGap` through a provider + wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while + the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap + events, snapshots, and explicit `sequenceChars`. Both wrappers now route the + complete contract to the provider that owns the session, including requested + 50,000-row recovery and zero-advance query-salvage events. + +9. **Sequence-safe recovery was added without advancing the daemon protocol.** + An already-running v19 daemon could accept background-thinning hints but + could not return the new `outputSequence`, making any resulting gap + impossible to reconcile safely. The authoritative snapshot contract is now + protocol v20. Preserved v19 sessions remain live but are explicitly marked + unthinned; their stale background hint is cleared on the ordered control + socket before `createOrAttach`, while fresh v20 sessions retain keep-tail + performance and full-model recovery. + +### Static audit conclusions + +- Model query authority still captures ownership synchronously at ingestion; + seed/hydration/snapshot writes remain reply-silent, remote view subscribers + retain view authority, and replies use the provider input path (including + daemon shell-ready queuing and SSH routing). +- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared + by the centralized PTY teardown path. Parked watcher timers, byte sidecars, + fact consumers, exit subscriptions, hidden claims, and runtime-title slots + dispose on reveal/exit/worktree shutdown. +- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden + panes still have a main-owned headless model, so mounted hidden-gate restore + is valid; live remote viewers veto daemon background thinning. The new + two-buffer payload is recomposed before mobile/remote snapshot frames. +- Wake recovery keeps its focus/visibility/system-resume listener symmetry and + cancels its settled animation frame on cleanup. No timeout was added that + mutates ACK or delivery counters. +- The one documented upstream SerializeAddon null-cell/wrapped-row defect + remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row + predicate as fidelity fuzz, rather than misclassifying that known serializer + defect as sequence-reconciliation loss. + +### Validation evidence + +- Focused ownership/connection/dispatcher tests: 422 passed. +- Daemon server/batcher/order tests passed, including deep queued-socket exit. +- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale + mocks were updated to assert the new explicit `sequenceChars` argument; the + production behavior was already correct. +- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed. +- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed. +- Post-protocol completion sweep: 1,106 affected main/daemon tests passed; + 484 renderer/restore tests passed with 2 expected skips. +- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`. +- Required hidden-view parking, parked-memory, and sleep/wake E2E trio: + 7 passed on a fresh v20 Electron build, including byte-identical output + across 25 park/reveal cycles. +- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s). +- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It + reproducibly found the SGR boundary bug at seed 16 and the known upstream + wrapped-null-cell case at seed 1221 before the final green run. +- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and + remote incomplete-escape regression suites passed. +- Fullscreen real-app headful flow: a real shell wrote normal history, entered + a TUI while its worktree was hidden, restored on reveal, then exited with + `?1049l` back to the original history. The BrowserWindow was fullscreen; + no click/focus occurred before evidence; the restored frame settled for + 1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63; + `fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap + 11px (the scrollbar/remainder, with grid and proposed dimensions equal). + Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and + `.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`. +- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s + (986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms, + DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39, + reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result: + `tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`. +- `pnpm typecheck`, oxlint on every touched TypeScript file, + `pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production + build all passed. No max-lines bypass was added. diff --git a/notes/terminal-performance-initiative.md b/notes/terminal-performance-initiative.md new file mode 100644 index 00000000000..2ac56bbf9e4 --- /dev/null +++ b/notes/terminal-performance-initiative.md @@ -0,0 +1,583 @@ +# Terminal Performance Initiative + +Working plan for the `orca-performance` branch. Goal: make Orca's terminal as +performant as the architecture allows, with every claim backed by a number. +Started 2026-07-02. + +## Why (user-reported, from the team meeting) + +1. Typing in the terminal is sometimes laggy — occasionally seconds of delay. +2. Users say the terminal is slower than iTerm (unclear if typing or scrolling). +3. Scrolling in Claude Code / OpenCode is slow. +4. Idle memory is high (1–2 GB). +5. Battery usage is high. + +Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling +latency down significantly; lower memory with 0–1 agents. + +## Ground truth (verified against source, 2026-07-02) + +Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified +file:line claims) — see the archived digest and the "xterm.js vs Ghostty" +deep-dive. The Orca-specific findings below were re-verified against this +repo's code: + +- **Electron main sits on every terminal byte's path** (daemon → main → + renderer). VS Code ships the same xterm.js but bypasses main entirely: its + ptyHost is a UtilityProcess with a direct MessagePort to each renderer. +- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in + both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery + is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can + grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k + unacked chars (kernel backpressure blocks the shell). +- Renderer terminals share one thread with the entire React app; xterm.js + parses in 12 ms slices at a documented 5–35 MB/s ceiling. +- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`), + 5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines) + reflow on column resize. +- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms + (same-library reference), native class 5–10 ms. Realistic target: beat + VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99 + dominates perception). + +## Current state + +Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`): + +1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig + (see Benchmark protocol below). +2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards, + probe-certified replay release) + #7139 (cooperative drain: paced backlog + draining keeps typing responsive under floods). Post-merge on this base: + `pnpm typecheck` clean, 626 targeted tests green (scheduler, guards, + pty/pty-connection/pty-transport suites). #7153 itself is a disposable + testing PR; #7139 and #7150 land separately on main. + +## Workstreams + +### 1. Baseline benchmarks (now; human-in-terminal required) + +Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty, +Terminal.app, VS Code (T3Code if available): + +``` +node tools/benchmarks/terminal-pipeline-bench.mjs --label - +node tools/benchmarks/terminal-pipeline-bench.mjs report +``` + +These numbers answer "are we actually slower than iTerm, and where," and are +the before/after for everything below. + +### 2. Validate #7153 on orca-performance (this week, extended testing) + +Watch for: typing responsiveness under agent floods, bounded memory, +skip-notice + snapshot repaint on overflow, no permanent input loss. When +validated, land #7139 and #7150 as separate PRs on main. + +### 3. Revive term-speed-2 (the headline structural work) + +History: nwparker's ~38-branch chain (+20k lines) implementing the terminal +model/view contract — hidden view parking, hidden delivery gate, side-effect +authority in main, model query authority, skip-grammar deletion — all +kill-switched, documented in +`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`. +It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was +never rejected and never reached main. Directly targets complaints 3–5 +(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases). + +Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34 +conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16), +`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5). +`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the +chain touch different layers; runtime interaction (drain pacing × hidden +gate) still needs deliberate testing. + +Execution: dedicated focused session; resolve on `revive/term-speed-2` off +orca-performance; keep both sides' kill switches; validate with typecheck + +the contract tests listed in the model-view-contract doc + #7153's suites; +merge back to orca-performance for extended testing. Estimated ~1 day of +careful resolution + validation. + +### 4. Remaining stall-bug fixes (parallel, independently shippable) + +The "seconds of delay" class = discrete thread-blocking events, not +steady-state latency: + +- PR #7105 (open): skip synchronous cold-restore replay for live daemon + sessions in doSpawn. +- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback + rows (#5096 follow-up, never done). Call sites include the mobile snapshot + path (`pty-connection.ts:2861`) and sleep/hibernate serialization. +- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped + buffers (repro harness exists). +- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s + tick; small, part of the same program). + +### 5. Producer-side PTY flow control + +Ack-driven pause/resume of the actual PTY through the daemon protocol +(node-pty supports it), watermarks per the xterm.js flow-control guide +(≤500 KB). Converts flood-induced buffered lag into shell blocking — the +correct physics. Sequence after #7139 lands (interacts with its drain pacing). + +Design (2026-07-03, implement after the term-speed-2 revival merges — +same files): + +- Signal source: main already tracks per-pty pending + in-flight + (`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a + pty's pending exceeds HIGH (256 KB), main asks the producer to pause; + below LOW (32 KB), resume. +- Producer side: two new protocol notifications (`pausePty`/`resumePty`, + protocol vNext, version-gated like `supportsIncrementalCheckpoints`); + daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the + pty fd, kernel buffer fills, the shell blocks on write: true kernel + backpressure, identical physics to VS Code's 100k/5k design. + `LocalPtyProvider` calls pause/resume directly. +- Safety invariants: (1) failsafe auto-resume after 5 s regardless of + watermark, so a lost resume can never wedge a shell; (2) resume on + detach/exit/kill/daemon-reconnect; (3) pause must not suppress the + interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the + HIGH watermark is only reachable during genuine floods where echo is + already queued; (4) PTY reads never stop for model/tail ingestion + (term-speed-2 invariant #1) — pause gates the fd read, so daemon-side + emulator state pauses with it, which is correct (state = what was read). +- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit + cleanup, plus an e2e pressure scenario asserting bounded main memory and + a blocked producer (`yes` exits promptly on SIGINT while paused). + +### 6. Extend the measurement rig + +- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops + at the parser reply, before paint). +- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling + at 0/1/5 agents (goal-3 metric). +- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in + main/daemon/renderer behind a debug flag for pipeline attribution. + +### 7. utilityProcess terminal router (structural endgame; gated on data) + +An Electron UtilityProcess owns the daemon socket and hands each renderer a +MessagePort — VS Code's topology while keeping Orca's detached daemon (warm +reattach). Takes main off the terminal data path entirely; daemon-side +history persistence falls out naturally. Prototype only after baselines show +how much tail latency lives in the main hop. + +### 8. Production P90 telemetry + +Sampled keypress→echo latency + long-task/stall counts from real users; +defines the success criterion and becomes the permanent regression gate. +Design after the local rig stabilizes so the metrics match. + +## Benchmark protocol + +`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any +terminal: + +- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only + after the parser reaches the query, so it proxies the input pipeline + without keystroke injection. +- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`, + `agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints — + and labeled-pathological `styles-stress`), each run ended by a DSR fence so + xterm.js-class ingest queues can't flatter the result. +- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI + stream: "typing while the agent works," quantified. + +Rules: same machine, AC power, comparable window size, no tmux/screen, hands +off the keyboard during runs. Never compare numbers across machines. + +## Sequencing + +``` +now: [1] baselines [2] #7153 testing (parallel) +next: [3] term-speed-2 revival (dedicated session) +parallel: [4] stall fixes, [6] rig extensions +after 2/3: [5] flow control +gated: [7] utility router [8] telemetry +``` + +BMW-group crash work remains the team's priority gate above all of this +(#7150's wedge guards overlap it); this plan runs measurement and revival +prep in parallel without displacing it. + +## Findings log + +### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/) + +Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty +1.3.1; iTerm2 not installed, VS Code pending): + +| metric | Orca prod | Terminal.app | Ghostty | +|---|---|---|---| +| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 | +| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 | +| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 | +| ascii-log fenced throughput | 13 MB/s | 39 | 93 | + +Decomposition of the 51× agent-tui gap — both pipeline ends are fast: + +- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s** + (`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem. +- Daemon `Session` ingest (emulator + pending-output recording + fanout): + agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`, + `ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem. + +Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms, +so the utilityProcess router is deprioritized by data; (2) the crisis is +queueing between daemon egress and renderer parse completion — main +per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after +renderer write callbacks, so renderer slowness throttles delivery +multiplicatively), and renderer per-chunk layers above xterm; (3) the +agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain +text inside Orca while being equal-cost everywhere else — profile it in the +renderer first (task #9). + +### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only) + +Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode +overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** — +the cooperative-drain branch does not move the under-load class. In +hindsight this is structural: DSR replies are ordered within the output +stream, so the metric measures output-queue depth; #7139 paces draining to +protect input-send responsiveness but cannot reorder the queue. Implications: +(1) the 134 ms-class number is fixed only by shrinking the queue (producer +flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's +own wins (freeze class, bounded memory, input-loss guards) must be validated +with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs +are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom. + +### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9) + +The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained +at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background) +regardless of parse speed. Isolation bench (fake timers, instant-parse +terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`, +`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching +prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only +when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the +sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's +`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks +to the queue, so floods always ride the drip. + +Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** — +a pacer re-arms a 0ms drain when xterm's write callback confirms the batch +parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling: +27 → **117.6 MB/s** (parse-limited). Background cadence deliberately +unchanged (protects the focused pane; hidden panes are term-speed-2's job). +`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of +#7139 preserved; its budget-yield test still passes). 621 tests green. + +Open follow-ups from this attribution: (a) end-to-end dev verification (in +progress); (b) whether main's `background:true` delivery marking demotes +visible-pane floods to the background drip — check +`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log +gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect +scanning; profile after (a). + +### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk + +Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered +probes (renderer scheduler counters → main whole-method timer → per-section +timers → targeted micro-benches) attributed it fully: + +- Renderer receives only ~350–770 KB/s — it is **starved**, not slow. +- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the + flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk). +- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless + track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the + pty-record tail block. +- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame + containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32× + the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer` + materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and + `finalizeRetainedTerminalRows` allocates them all again plus runs a + trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes + per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code + frame (cursor-up + erase-below) takes this path; plain logs don't — which + is exactly the measured agent-tui vs ascii asymmetry. + +Chain: TUI flood → O(tail) work per chunk in main → main event loop +saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s → +deep queue → 134 ms DSR-under-load. + +Fix (in progress): run the existing algorithm on a lazy suffix window (the +cursor's maximum upward reach, computed from the chunk) with the untouched +prefix shared by reference; differential fuzz test proves output equality +against the original implementation. Worst case (pathological full-height +cursor-up) falls back to today's cost. + +### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued + +Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same +protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under- +load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the +first time, but the pipeline is still far from the renderer's 27–117 MB/s +capacity — another main-side consumer remains hot. + +Next cycle (exact recipe): re-apply the whole-method main probe +(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed +build. If onPtyData still dominates, the remaining O(tail)/per-chunk +suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full +tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in +dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured); +(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If +onPtyData no longer dominates, probe the main→renderer delivery batching +next. The probe/bench cycle is mechanical: relaunch dev +(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command +" --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log. + +### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale + +Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still +~90% of main's event loop (~930 ms/s). Bucket split per second: +**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed +fix confirmed), normalize ≈ 7, preview ≈ 0. + +Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its +callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText` +joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both +(another ~512KB of string allocation per chunk) and runs multi-pattern +blocked/ready scans (`findTerminalWaitBlockedSignal`, +`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text) +— all to timestamp `waitBlockedAt` for `terminal wait`. + +Fix design (next session): blocked/ready prompts are end-anchored — an +actionable prompt is at the END of output. (1) Run the check on a bounded +suffix of the wait text (last ~64 lines / 16KB) instead of the full tail; +(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small +carry for split keywords) can contain a blocked keyword; (3) build the two +wait texts only when the check runs. Verification mirrors the windowed-tail +pattern: keep the full-text check as reference + differential fuzz over +randomized tails/prompts (split-across-chunks cases included — the +`appendCandidateSignal` ordering semantics at :23146 must be preserved), +plus the terminal-wait contract tests. Expected effect: removes ~85% of +remaining onPtyData cost; combined with the two landed fixes should +finally unlock the pipeline toward the renderer's measured 27–117 MB/s. + +### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency + +Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243, +windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label +dev-blockedfix, same protocol/config as prior dev rows: + +| metric | pre-fix dev | +tail fix | +blocked fix | +|---|---|---|---| +| agent-tui MB/s | 0.7 | 1.0 | **11.5** | +| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** | +| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** | +| ascii-log MB/s | 6.4 | 4.7 | **9.6** | + +The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The +throttled blocked-check delivered the predicted ~85% cut. Dev mode carries +~2× overhead vs prod, so the prod build should land near ~10ms DSR-under- +load — from the 134ms baseline (~13×) — pending a packaged-build rerun. +Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain +ticks + xterm 12ms slices), which flow control (#6) does not target; +re-evaluate the "within 10× of Terminal.app" goal line after a prod +measurement. Next: term-speed-2 revival (#4), then flow control (#6). + +### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate) + +`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files, +typecheck clean, ~2,776 targeted tests green, all three of our fixes +verified present, chain features present and kill-switched (subagent's +six review risks recorded in its report). Bench verdict on the revived +build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35% +unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round +proved the kill switches are NOT the cost) and idle p50 doubled. + +Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s +≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon +CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×) +on the revive branch** (`session-ingest-throughput.bench.test.ts`, +ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator +restructure (scanner classes / query-reply forwarding / view-attribute +responder) added per-byte cost to the daemon hot path. Chunks reaching +main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger). + +NEXT (fast inner loop — pure unit bench, no app restarts): on +revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs +7839fb9db, find the per-chunk scanner cost, restore our bounded-parser +fast paths (the daemon emulator must never pay per-byte JS scanning for +bytes that contain no ESC — same pre-filter pattern as the blocked-check +keyword bypass), verify with the ingest bench back at ~100 MB/s, then +full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to +orca-performance. A residual renderer-side share is possible once the +daemon is fixed — re-attribute after. + +Merge gate: revive branch merges only at ≥ blockedfix numbers. + +**RETRACTION (2026-07-03, later):** the daemon conviction above was a +confounded measurement — the 39–48 MB/s ingest runs executed while a dev +app was still running. On a quiet machine the revive branch ingests at +**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s +vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end +revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs +were load-controlled, and unit benches show up to 2.6× machine-load +variance. Scanner pre-filters landed anyway on revive (71c89da9b; +strictly positive, 641 daemon tests green). + +**New measurement protocol (mandatory from here):** quiet machine (no dev +apps or benches concurrent), paired A/B runs back-to-back alternating +branches, n≥2 per side, report spread not just p50. The merge-gate +comparison (blockedfix vs revive) must be redone under this protocol +before any verdict. Next: run the controlled A/B; if the delta +disappears, merge revive into orca-performance and proceed to flow +control (#6); if it persists, resume attribution renderer-side (probe +pty-connection dataCallback additions per chunk). + +### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance + +Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui + +DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive +6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50 +tied, p99 better on revive, throughput within overlapping noise (revive2's +3.6 followed two runtime-busy create failures). The earlier "35% +regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs +11.5 yesterday — dev benches carry ~2x day-to-day machine variance; +absolute dev numbers are only comparable within one A/B session. + +Merged revive/term-speed-2 → orca-performance; typecheck clean, 288 +post-merge spot tests green. orca-performance now = main-ish base + #7153 ++ three perf fixes + full term-speed-2 chain (kill-switched, default ON) ++ scanner pre-filters. Extended user testing now covers everything. +Remaining from the revival agent's risk list: gate×drain e2e specs +(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them. +Next: producer flow control (#6) per design §5; prod packaged-build bench +for the real headline numbers. + +### 2026-07-03 — flow control merged; goal-state accounting + +Producer flow control merged to orca-performance (348aeb325): protocol +v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData, +node-pty kernel backpressure, 5s daemon-side lost-resume failsafe + +main-side pause re-assert, resume on every teardown path, version-gated +(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED` +(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests +green. + +**Definition-of-done accounting:** +- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s + and DSR-load p50 161→18.8 dev, results committed). +- term-speed-2: REVIVED AND MERGED (A/B gate passed). +- Flow control: IMPLEMENTED AND MERGED. +- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged + RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod + projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The + remaining gap is structural cadence (daemon 8ms batch, renderer drain + ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the + waste class this initiative eliminated. Prod verification path: + electron-vite preview CANNOT host the bench (CLI-created panes are not + adopted by the preview window's renderer → no ACKs → pending-cap drop; + two attempts, documented) — measure on the next packaged RC cut from + orca-performance using the committed rig + protocol instead. + +**Deferred, ordered:** (1) sync orca-performance with main — conflicts +incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era +`Metadata=12`; renumber chain side, audit mobile/web stream consumers); +(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) — +gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig +extensions + P90 telemetry (tasks #3/#8). + +### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers) + +Same rig, same protocol, same machine as the 1.4.91 baseline: + +| metric | 1.4.91 baseline | v1.4.121-rc.0 | change | +|---|---|---|---| +| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) | +| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x | +| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x | +| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x | +| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x | +| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x | +| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x | + +Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at +a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse +CPU (prod ≈ dev for both latency and throughput; the pipeline is +cadence-bound, so faster prod code changes nothing). That uniform cap +also explains plain-text dipping slightly below baseline: ascii/cjk used +to run unpaced ahead of the old scheduler; now everything flows through +the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app +under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at +parity. The remaining 4x is the named cadence stack (daemon 8 ms batch, +scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) — +next lever, tunable, tracked as follow-up. p99 tail (the freeze class) +is 29.7 ms — users cannot perceive it. + +Caveat: measured on the user's live app (this session active in it); +idle p99 118 ms reflects that activity, not the terminal path. + +### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig) + +| metric | Orca v1.4.121-rc.0 | VS Code | verdict | +|---|---|---|---| +| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster | +| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster | +| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail | +| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% | +| cjk-emoji | 12.2 | 11.3 | tie | +| agent-tui | 11.2 | 11.7 | tie | +| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x | + +Orca now beats or ties the best-known xterm.js terminal on 5 of 6 +metrics — including 16x at idle (what users feel all day) and 5x on +SGR-heavy output — and holds a better p99 tail under load. Throughput +sits at the shared engine ceiling (~9-12 MB/s), confirming the class +limit. + +The one loss (load p50) has a clean mechanism: VS Code's producer flow +control caps unacked output at ~100KB, so its standing queue is +~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue +(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window +(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested +setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel +drain scheduling (sub-ms re-arm; also raises the throughput ceiling); +(2) tighter effective in-flight window on the renderer delivery path. +Target: VS Code's ~7ms class or below without giving back throughput. + +### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms + +Lever results (dev, 3MB protocol, same session): +- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the + ~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util) + there is no standing queue. Kept (correct, removes a real clamp). +- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon + STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS): + **p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1). + Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239 + batcher+pty tests green after timing updates. + +Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should +land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining +fixed-latency terms are renderer/xterm-internal (12ms parse slices). +Note: main's interactive bypass (input-gated) means real keystroke echo +skips batching entirely — the DSR metric understates real typing +responsiveness; VS Code measured on the same freight path, comparison +fair. + +Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect +~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in +reach). + +### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green + +terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/ +title side effects live while parked), terminal-sleep-wake-restore +(output restored + input accepted after wake), terminal-parked-memory +(renderer memory released on park; views retained when kill-switched +off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay — +the revival's top flagged risk — now has e2e coverage on the exact +branch the RC ships from. Remaining garble-hardening: differential +hide/reveal fuzz harness (next build), reveal-time seq diagnostics. + +## Success criteria (baseline-relative; finalize after task 1) + +- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same + box; zero DSR timeouts (today's freeze class). +- Fenced agent-tui throughput ≥ VS Code on the same box. +- Idle RSS with 0–1 agents materially down (target set after the memory + harness lands; hidden-pane parking is the main lever). +- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood. +- Production P90 typing latency down and monitored continuously. diff --git a/package.json b/package.json index 4bae0747aae..b9a3f9d124b 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", + "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", @@ -91,6 +92,7 @@ "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", + "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { @@ -257,7 +259,8 @@ "patchedDependencies": { "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", - "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch" + "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", + "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf5fc522fd1..b17bd6adb8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.287': hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch + '@xterm/addon-serialize@0.15.0-beta.287': + hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 + path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch @@ -36,7 +39,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287) + version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -9288,7 +9291,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287 - '@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)': dependencies: '@xterm/xterm': 6.1.0-beta.287 diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts new file mode 100644 index 00000000000..acfcdb959f4 --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonTransientFact } from './types' + +function createRelay() { + const emitted: { sessionId: string; fact: DaemonTransientFact }[] = [] + const relay = new BackgroundTransientFactRelay((sessionId, fact) => + emitted.push({ sessionId, fact }) + ) + return { relay, emitted } +} + +describe('BackgroundTransientFactRelay', () => { + it('emits a bell fact for a backgrounded session', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', 'build output\x07more') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }]) + }) + + it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]0;my working title') + relay.onSessionData('s1', ' continued\x07') + expect(emitted).toEqual([]) + }) + + it('emits nothing for sessions that are not backgrounded', () => { + const { relay, emitted } = createRelay() + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('emits command-finished with the OSC 133;D exit code', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]133;D;0\x07') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }]) + }) + + it('stops emitting after un-background and reports the toggle as a state change', () => { + const { relay, emitted } = createRelay() + expect(relay.setSessionBackground('s1', true)).toBe(true) + expect(relay.setSessionBackground('s1', true)).toBe(false) + expect(relay.setSessionBackground('s1', false)).toBe(true) + expect(relay.setSessionBackground('s1', false)).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('drops the tracker on session exit', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionExit('s1') + expect(relay.isBackgrounded('s1')).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('never arms the stale-working-title timer (titles are main-authoritative)', () => { + vi.useFakeTimers() + try { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + // A working-spinner title followed by title-less output would arm the + // 3s stale timer if titles were being tracked. + relay.onSessionData('s1', '\x1b]0;⠋ Claude\x07') + relay.onSessionData('s1', 'output without titles') + expect(vi.getTimerCount()).toBe(0) + expect(emitted).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts new file mode 100644 index 00000000000..3867ab0eddd --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -0,0 +1,106 @@ +/** + * Daemon-side transient-fact scanning for backgrounded sessions. + * + * While a session is backgrounded (its pane hidden in the renderer), the + * daemon→main stream copy may be keep-tail thinned under backlog — but the + * notification-bearing facts inside those bytes must never be lost. This + * relay runs the SAME shared scanners main uses (terminal-side-effect + * authority doc: semantics must not drift) over every raw chunk BEFORE any + * drop decision, and emits compact transientFact stream events in byte order. + * Main suppresses its own copies of these four scanners between the + * sessionBackgroundMarker handoffs, so no fact double-fires or goes missing. + * + * Title/agent-status facts are deliberately NOT relayed: they converge from + * the delivered kept tail (stale-working-title timer, snapshot-restores-title + * -state) and main fuses them with synthetic spinner frames the daemon never + * sees. + */ +import { + createTerminalTitleTracker, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import type { DaemonTransientFact } from './types' + +// Kill switch for the whole background keep-tail mechanism (thinning + +// daemon-side fact authority): ORCA_DAEMON_BACKGROUND_STREAM_DROP=0. +export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND_STREAM_DROP !== '0' + +export class BackgroundTransientFactRelay { + private trackersBySessionId = new Map() + private emitFact: (sessionId: string, fact: DaemonTransientFact) => void + + constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) { + this.emitFact = emitFact + } + + isBackgrounded(sessionId: string): boolean { + return this.trackersBySessionId.has(sessionId) + } + + backgroundedSessionIdSuffixes(): string[] { + return Array.from(this.trackersBySessionId.keys(), (id) => id.slice(-10)) + } + + /** Returns false when this was a no-op (already in the requested state) so + * the caller can skip a duplicate handoff marker — resyncs after adoption + * re-send the whole background set. */ + setSessionBackground(sessionId: string, background: boolean): boolean { + if (background === this.isBackgrounded(sessionId)) { + return false + } + if (background) { + this.trackersBySessionId.set( + sessionId, + createTerminalTitleTracker({ + onBell: () => this.emitFact(sessionId, { kind: 'bell' }), + onCommandFinished: (exitCode) => + this.emitFact(sessionId, { kind: 'command-finished', exitCode }), + // Note: recreating the tracker on each background toggle resets the + // PR-link dedup memory, so a link re-printed across toggles can + // re-fire — consumers treat pr-link as a latest-association update. + onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), + onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }) + }) + ) + } else { + this.disposeTracker(sessionId) + } + return true + } + + /** Prime a fresh tracker's cross-chunk carry with the emulator's dangling + * incomplete escape at handoff time, so a sequence split across the + * background toggle neither mints a phantom bell nor loses its fact. A + * partial tail contains no complete sequence, so this can never fire. */ + seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void { + if (partialEscapeTailAnsi.length > 0) { + this.trackersBySessionId + .get(sessionId) + ?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' }) + } + } + + /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — + * facts must be captured even when the chunk is later keep-tail dropped. */ + onSessionData(sessionId: string, data: string): void { + // titleScanData:'' skips title extraction (titles stay main-authoritative) + // and keeps the stale-working-title timer permanently unarmed — only the + // four transient scanners consume the chunk. + this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) + } + + onSessionExit(sessionId: string): void { + this.disposeTracker(sessionId) + } + + dispose(): void { + for (const sessionId of Array.from(this.trackersBySessionId.keys())) { + this.disposeTracker(sessionId) + } + } + + private disposeTracker(sessionId: string): void { + this.trackersBySessionId.get(sessionId)?.dispose() + this.trackersBySessionId.delete(sessionId) + } +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 9dfc97532fa..34c04043950 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -131,7 +131,9 @@ async function main(): Promise { // Signal readiness to parent via IPC (if available) if (process.send) { - process.send({ type: 'ready' }) + // Why: Windows has no cheap OS query for a child's start time, so the + // daemon self-reports it here for the pid file's pid-recycling guard. + process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts new file mode 100644 index 00000000000..99d19818a3c --- /dev/null +++ b/src/main/daemon/daemon-errors.ts @@ -0,0 +1,23 @@ +// Error classes shared across the daemon protocol boundary (client, server, +// host). Split from types.ts, which is capped for wire-shape declarations. + +export class TerminalAttachCanceledError extends Error { + constructor(sessionId: string) { + super(`Attach canceled for session ${sessionId}`) + this.name = 'TerminalAttachCanceledError' + } +} + +export class DaemonProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'DaemonProtocolError' + } +} + +export class SessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Session not found: ${sessionId}`) + this.name = 'SessionNotFoundError' + } +} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 94d0564d09b..defbc8fd09a 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -13,7 +13,9 @@ import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks, parseDaemonPidFile, - startTimeMatches + parseWindowsProcessIdentityJson, + startTimeMatches, + startTimesWithinTolerance } from './daemon-health' import type { SubprocessHandle } from './session' @@ -125,6 +127,26 @@ describe('daemon health', () => { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) }) + it('classifies a hello-rejected daemon as rejected, not unreachable', async () => { + // Why: 'rejected' means the daemon answered and refused adoption — the + // launcher may replace it. 'unreachable' also covers a wedged-but-live + // daemon, which must never be replaced while its pipe accepts connections. + const server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + writeFileSync(tokenPath, 'not-the-daemon-token', { mode: 0o600 }) + await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('rejected') + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) + } finally { + await server.shutdown() + } + }) + it('does not unlink a live socket when the pid file does not match this daemon', async () => { if (process.platform === 'win32') { return @@ -286,6 +308,42 @@ describe('startTimeMatches', () => { }) }) +describe('parseWindowsProcessIdentityJson', () => { + it('parses command line and start time from the CIM query output', () => { + expect( + parseWindowsProcessIdentityJson( + '{"cmd":"Orca.exe daemon-entry.js","start":1700000000000}\r\n' + ) + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: 1_700_000_000_000 }) + }) + + it('returns a null start time when CreationDate was unavailable', () => { + expect( + parseWindowsProcessIdentityJson('{"cmd":"Orca.exe daemon-entry.js","start":null}') + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: null }) + }) + + it('returns null for a missing process or inaccessible command line', () => { + expect(parseWindowsProcessIdentityJson('')).toBeNull() + expect(parseWindowsProcessIdentityJson(' \r\n')).toBeNull() + expect(parseWindowsProcessIdentityJson('{"cmd":null,"start":123}')).toBeNull() + expect(parseWindowsProcessIdentityJson('not-json')).toBeNull() + }) +}) + +describe('startTimesWithinTolerance', () => { + it('fails open when either side is null', () => { + expect(startTimesWithinTolerance(null, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_000_000, null, 1_500)).toBe(true) + expect(startTimesWithinTolerance(null, null, 1_500)).toBe(true) + }) + + it('matches within tolerance and rejects outside it', () => { + expect(startTimesWithinTolerance(1_700_000_001_000, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_005_000, 1_700_000_000_000, 1_500)).toBe(false) + }) +}) + describe('killStaleDaemon pid identity guards', () => { let dir: string let socketPath: string diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 96d4a885dea..50e7226f73c 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -24,8 +24,17 @@ const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 +// Why: on Windows the pid file's startedAtMs is the daemon's self-reported +// Node start time, while verification reads the OS process creation time — +// the gap between them is the exe bootstrap, which AV/disk pressure can +// stretch to seconds. Pid recycling differs by minutes-to-days, so a wide +// tolerance keeps the guard effective without false mismatches. +const WIN32_START_TIME_TOLERANCE_MS = 10_000 -export type DaemonHealth = 'healthy' | 'unreachable' | 'pty-spawn-unhealthy' +// 'rejected' means the daemon answered and refused the handshake (bad token, +// foreign protocol) — it can never be adopted, unlike 'unreachable', which +// also covers a live-but-wedged daemon that simply missed the RPC budget. +export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' type ParsedDaemonPid = { pid: number @@ -134,13 +143,13 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis try { message = JSON.parse(line) as Record } catch { - settle('unreachable') + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle('unreachable') + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -371,6 +380,10 @@ export function getProcessStartedAtMs(pid: number): number | null { } if (process.platform === 'win32') { + // Why: the only OS source is a CIM query costing a powershell spawn — + // too slow for this sync path. Windows pid files instead carry the + // daemon's self-reported start time from its ready message, and + // isDaemonProcess verifies it against CIM CreationDate asynchronously. return null } @@ -387,27 +400,61 @@ export function getProcessStartedAtMs(pid: number): number | null { } export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean { - if (expectedStartedAtMs === null) { + return startTimesWithinTolerance( + getProcessStartedAtMs(pid), + expectedStartedAtMs, + START_TIME_TOLERANCE_MS + ) +} + +// Why: fail open on null — a pid file or OS query without a start time must +// not veto an otherwise-matching daemon (adoption safety beats recycle safety). +export function startTimesWithinTolerance( + actualStartedAtMs: number | null, + expectedStartedAtMs: number | null, + toleranceMs: number +): boolean { + if (expectedStartedAtMs === null || actualStartedAtMs === null) { return true } - - const actualStartedAtMs = getProcessStartedAtMs(pid) - if (actualStartedAtMs === null) { - return true - } - - return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS + return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= toleranceMs } const execFileAsync = promisify(execFile) +export type WindowsProcessIdentity = { + commandLine: string + startedAtMs: number | null +} + +export function parseWindowsProcessIdentityJson(stdout: string): WindowsProcessIdentity | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { cmd?: unknown; start?: unknown } + if (typeof parsed.cmd !== 'string' || !parsed.cmd) { + return null + } + return { + commandLine: parsed.cmd, + startedAtMs: + typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null + } + } catch { + return null + } +} + // Why: the only reliable command-line source on Windows is a CIM query, which // costs a full powershell.exe spawn (300-800ms cold, worse under Defender). // Async because the sync version measurably froze the Electron main thread at // startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold). -// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can -// attribute startup cost to these checks. -async function queryWindowsProcessCommandLine(pid: number): Promise { +// CreationDate rides along in the same spawn so start-time verification adds +// zero extra process launches. Timed under ORCA_STARTUP_DIAGNOSTICS so the +// cold-start benchmark can attribute startup cost to these checks. +async function queryWindowsProcessIdentity(pid: number): Promise { const startedAt = performance.now() try { const { stdout } = await execFileAsync( @@ -416,14 +463,17 @@ async function queryWindowsProcessCommandLine(pid: number): Promise { if (process.platform === 'win32') { - return queryWindowsProcessCommandLine(pid) + return (await queryWindowsProcessIdentity(pid))?.commandLine ?? null } try { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 39f05c641ee..06344c0759f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -25,6 +25,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -34,6 +36,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -51,6 +54,13 @@ const { const probeSocketExistsMock = vi.fn((_path?: string) => false) const writeFileSyncMock = vi.fn() + // Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive's + // catch treats every legacy pid file as unreadable — matching the pre-fix + // cleanup behavior every existing test was written against. + const readFileSyncMock = vi.fn((): string => { + throw new Error('ENOENT') + }) + const unlinkSyncMock = vi.fn() const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: the real probeSocket() in daemon-init connects to the socket and @@ -83,7 +93,10 @@ const { const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) const killStaleDaemonMock = vi.fn(async () => true) - const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) + const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) + const parseDaemonPidFileMock = vi.fn( + (): { pid: number; startedAtMs: number | null } | null => null + ) const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { @@ -145,6 +158,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -154,6 +169,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -214,7 +230,8 @@ vi.mock('electron', () => ({ vi.mock('fs', () => ({ mkdirSync: vi.fn(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), - unlinkSync: vi.fn(), + unlinkSync: unlinkSyncMock, + readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock })) @@ -229,7 +246,8 @@ vi.mock('./daemon-health', () => ({ healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, - getProcessStartedAtMs: getProcessStartedAtMsMock + getProcessStartedAtMs: getProcessStartedAtMsMock, + parseDaemonPidFile: parseDaemonPidFileMock })) vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) @@ -358,6 +376,15 @@ async function importFresh() { daemonClientMock.mockClear() probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() + readFileSyncMock.mockReset() + readFileSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + unlinkSyncMock.mockClear() + parseDaemonPidFileMock.mockReset() + parseDaemonPidFileMock.mockReturnValue(null) + getProcessStartedAtMsMock.mockReset() + getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: importing daemon-init *after* resetModules means the module-level // `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is // the only way to reliably exercise the "first-time init" path and the @@ -1551,7 +1578,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => { + it('replaces a health-check-failing daemon when live sessions cannot be verified and the pipe is dead', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1595,6 +1622,203 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() }) + it('adopts an unresponsive daemon whose pipe still accepts connections (update-relaunch wedge)', async () => { + // Why: the Windows update-relaunch regression — post-install disk/AV load + // wedges the daemon past the 3s health budget AND the 5s hello budget of + // the session-list re-verification, while its sessions are still alive. + // The old fail-closed path killed the daemon here. A pipe that accepts a + // raw connection proves the daemon is alive, so the launcher must adopt. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello response timed out') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + // The raw pipe probe succeeds even though every RPC timed out. + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => { + // Why: 'rejected' means the daemon answered and refused the handshake — + // it can never be adopted, so keeping it alive would strand the app with + // no terminals forever. Replacement stays the only recovery. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello rejected') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('rejected') + probeSocketExistsMock.mockReturnValue(true) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).toHaveBeenCalledWith( + FAKE_RUNTIME_DIR, + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalled() + }) + + it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { + // Why: the regression contract — a pid file with startedAtMs null (all + // pre-fix Windows pid files) resolves launch identity to 'unknown'. With + // a live daemon answering on the pipe, that must ADOPT, never replace. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown') + isPackagedMock.mockReturnValue(true) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => { + // Why: getProcessStartedAtMs has no cheap Windows implementation, so the + // pid file's pid-recycling guard depends on the ready-message fallback. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + getProcessStartedAtMsMock.mockReturnValue(null) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(writeFileSyncMock).toHaveBeenCalledWith( + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + JSON.stringify({ + pid: 12345, + startedAtMs: 1_700_000_123_456, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3' + }), + { mode: 0o600 } + ) + }) + + it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { + // Why: deleting a live-but-wedged legacy daemon's token file makes its + // sessions permanently unadoptable — no future launch could authenticate. + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // process.pid is guaranteed alive, so the liveness probe succeeds. + parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) + + await mod.initDaemonPtyProvider() + + const legacyUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid')) + ) + expect(legacyUnlinks).toEqual([]) + }) + + it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => { + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // Why: spy on process.kill so the liveness probe deterministically reports + // "no such process" without depending on an unallocated real pid. + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH') + }) + parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null }) + + try { + await mod.initDaemonPtyProvider() + } finally { + killSpy.mockRestore() + } + + const tokenUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && p.includes('.token') + ) + expect(tokenUnlinks.length).toBeGreaterThan(0) + }) + it('replaces a health-check-failing daemon when no live sessions would be lost', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 4de553cde16..ad0227bae86 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,7 +8,7 @@ module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' import { app } from 'electron' -import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { fork } from 'node:child_process' import { connect } from 'node:net' import { @@ -34,7 +34,8 @@ import { getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, - killStaleDaemon + killStaleDaemon, + parseDaemonPidFile } from './daemon-health' import { collectPinnedDaemonVersions, @@ -255,9 +256,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Why: a busy machine (e.g. right after an update) can time out the // health check while the daemon is alive and owning terminals. Killing // it would destroy every live session, so re-verify with a session list - // first. Only a verified non-empty list preserves: a daemon that cannot - // even list sessions cannot serve terminals, and replacing it is the - // only recovery. + // first. const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) if (liveSessionCount !== null && liveSessionCount > 0) { if (health === 'pty-spawn-unhealthy') { @@ -275,6 +274,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { ) return createPreservedDaemonHandle(runtimeDir) } + // Why: on a Windows update relaunch the daemon can be wedged past every + // RPC budget (final checkpoint flush + installer/AV disk pressure), so + // both the health check AND the session list time out while sessions + // are still alive — failing closed here is what killed those sessions. + // A pipe that still accepts connections proves a live daemon: adopt it + // and let the adapter reconnect once the daemon drains. 'rejected' + // means the daemon answered and refused the handshake — it can never be + // adopted, so replacement stays the only recovery. + if (liveSessionCount === null && health !== 'rejected' && (await probeSocket(socketPath))) { + console.warn( + '[daemon] Preserving unresponsive daemon because its socket still accepts connections' + ) + return createPreservedDaemonHandle(runtimeDir) + } } // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID @@ -398,11 +411,19 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // killStaleDaemon() can verify the pid still belongs to the daemon // we forked before SIGTERMing it. Prevents pid-recycling hazard // where the OS hands the daemon's old pid to an unrelated process. + // Why the ready-message fallback: Windows has no cheap OS query + // for start time, so the daemon self-reports it — without this the + // recycling guard was permanently inert on win32. + const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs writeFileSync( getDaemonPidPath(runtimeDir), serializeDaemonPidFile({ pid: child.pid, - startedAtMs: getProcessStartedAtMs(child.pid), + startedAtMs: + getProcessStartedAtMs(child.pid) ?? + (typeof selfReported === 'number' && Number.isFinite(selfReported) + ? selfReported + : null), entryPath, appVersion: app.getVersion() }), @@ -821,6 +842,21 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } +function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { + try { + const parsed = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + if (!parsed) { + return false + } + process.kill(parsed.pid, 0) + return true + } catch { + return false + } +} + async function createLegacyDaemonAdapters(runtimeDir: string): Promise { const adapters: DaemonPtyAdapter[] = [] for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { @@ -830,23 +866,27 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise void>> + resume: ReturnType void>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -43,6 +46,8 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), + pause: vi.fn<() => void>(), + resume: vi.fn<() => void>(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), forceKill: vi.fn(), signal: vi.fn(), @@ -185,6 +190,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('producer flow control', () => { + it('routes pausePty/resumePty notifications to the daemon session subprocess', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + adapter.resumeProducer(id) + await waitFor(() => lastSubprocess.resume.mock.calls.length > 0) + }) + + it('sends pause/resume as fire-and-forget notifications on the current protocol', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.pauseProducer(id) + adapter.resumeProducer(id) + expect(notifySpy).toHaveBeenCalledWith('pausePty', { sessionId: id }) + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + + it('never sends pause/resume notifications on a legacy protocol version', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 18 }) + try { + legacy.pauseProducer('legacy-session') + legacy.resumeProducer('legacy-session') + expect(notifySpy).not.toHaveBeenCalled() + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('owes paused sessions a resumePty on the next connect after a socket drop', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + // Drop the daemon out from under the adapter: the in-flight pause has no + // matching resume anymore. + await server.shutdown() + await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected()) + + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + // Any reconnecting operation must flush the owed resume first. + await adapter.listProcesses() + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + }) + + describe('background stream thinning compatibility', () => { + it('reports background state on the authoritative-snapshot protocol', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.setPtyBackgrounded('current-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'current-session', + background: true + }) + } finally { + notifySpy.mockRestore() + } + }) + + it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + legacy.setPtyBackgrounded('legacy-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('clears a preserved v19 background hint before attaching its stream', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } finally { + legacy.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + }) + describe('getAppliedSize', () => { it('reports the spawn dims before any resize', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -225,6 +359,23 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('getBufferSnapshot', () => { + it('returns the daemon model with its absolute stream sequence', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('complete hidden output\r\n') + + const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 123 }) + + expect(snapshot).toMatchObject({ + data: expect.stringContaining('complete hidden output'), + cols: 80, + rows: 24, + seq: 'complete hidden output\r\n'.length, + source: 'headless' + }) + }) + }) + describe('shutdown', () => { it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -884,6 +1035,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-checkpoint' + }) + const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint') + + lastSubprocess._simulateData('latest before sleep\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(checkpointSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') }) + ) + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + + const restored = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(restored.coldRestore?.scrollback).toContain('latest before sleep') + historyAdapter.ackColdRestore(id) + + const remountAfterAck = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(remountAfterAck.coldRestore).toBeUndefined() + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it('cold restores the second sleep/wake cycle with post-wake output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-wake-cycles' + }) + + lastSubprocess._simulateData('first cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json') + const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json') + // Why: keep-history sleep stays unclean so cold restore remains eligible; + // the final checkpoint is the deterministic handoff signal. + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'first cycle content' + ) + + const firstWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(firstWake.coldRestore?.scrollback).toContain('first cycle content') + historyAdapter.ackColdRestore(id) + expect(historyAdapter.hasPty(id)).toBe(true) + + lastSubprocess._simulateData('second cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'second cycle content' + ) + + const secondWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(secondWake.coldRestore?.scrollback).toContain('second cycle content') + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + it('writes meta.json with endedAt on exit', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index cac7f14e480..3535b410404 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -21,6 +21,8 @@ import { } from './types' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -79,8 +81,13 @@ export class DaemonPtyAdapter implements IPtyProvider { // lock, each would fork its own daemon process. This promise coalesces // concurrent respawns so only the first caller forks; the rest await it. private respawnPromise: Promise | null = null - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() // Why: React re-renders and StrictMode double-mounts can call createOrAttach @@ -93,6 +100,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // mount → ??? The sticky cache returns the same cold restore data on the // second mount until the renderer explicitly acknowledges it. private coldRestoreCache = new Map() + private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell whose on-disk checkpoint and @@ -109,6 +117,19 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: incremental checkpoints require the takePendingOutput RPC (v13+). // Against older daemons the tick falls back to full-snapshot checkpoints. private supportsIncrementalCheckpoints: boolean + // Why: producer pause/resume notifications require v19+; legacy daemons + // must never see them, so gating makes them silent no-ops there. + private supportsProducerFlowControl: boolean + private supportsAuthoritativeBufferSnapshots: boolean + private pausedProducerSessionIds = new Set() + // Why tracked here: the daemon's background set (keep-tail stream thinning + // + transient-fact scan authority) dies with the daemon process/socket; + // re-sync it on a fresh connection so hidden panes stay thinned. + private backgroundedSessionIds = new Set() + // Why: a daemon that survives a socket drop can still hold a pause whose + // resume died with the connection. Owe those sessions a resume on the next + // connect; the daemon's 5s failsafe covers the window in between. + private producerResumesOwedOnReconnect = new Set() private static CHECKPOINT_INTERVAL_MS = 5_000 // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB // snapshot checkpoint on every 5s tick via pending-buffer overflow or the @@ -133,6 +154,14 @@ export class DaemonPtyAdapter implements IPtyProvider { this.respawnFn = opts.respawn ?? null this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.client.onDisconnected(() => { + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + }) } getHistoryManager(): HistoryManager | null { @@ -155,6 +184,12 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() + // Why before createOrAttach: a preserved v19 daemon may remember this + // session as backgrounded. Ordered control delivery clears it before any + // newly attached stream bytes can be thinned without a recoverable seq. + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(sessionId, false) + } // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of @@ -232,6 +267,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // but should still return the cached cold restore data. const cachedRestore = this.coldRestoreCache.get(sessionId) if (cachedRestore) { + // Why: wake after sleep also lands here, and the slept session's active + // tracking and history writer were dropped when sleep killed the PTY. + // Without re-registering both, checkpoints stop after wake and the + // second sleep/wake cycle restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager) { + this.historyManager.reopenSession(sessionId) + } return { id: sessionId, pid, @@ -275,13 +318,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. if (restoreInfo && (result.isNew || result.historySeeded === false)) { - // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer - // (vim/less/htop); normal sessions use the full snapshot + rehydrate. - // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) - // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* - // (no rehydrateSequences — they start with \x1b[?1049h, which the - // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as - // normal scrollback. An empty snapshot still yields null → no-op. + const coldRestore = this.buildColdRestorePayload(restoreInfo) const canReanchorHistory = !scrollback || result.historySeeded === true // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before @@ -300,8 +337,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.historyManager.suspendSession(sessionId) } } - if (scrollback) { - const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + if (coldRestore) { this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, @@ -348,13 +384,24 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi + const snapshotPayload = + result.snapshot.scrollbackAnsi + + result.snapshot.rehydrateSequences + + result.snapshot.snapshotAnsi + // Why kitty flags ride beside the payload, not inside it: the snapshot + // string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's + // deliberate kitty reset must win. Only the runtime emulator re-seed + // consumes the flags (terminal-query-authority.md §kitty). + const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { id: sessionId, pid, snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: carry the mid-escape tail so the renderer can write it after the @@ -368,6 +415,9 @@ export class DaemonPtyAdapter implements IPtyProvider { async attach(id: string): Promise { await this.ensureConnected() + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(id, false) + } await this.client.request('createOrAttach', { sessionId: id, @@ -390,16 +440,62 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): a delivery hint for the daemon's + // keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved v19 daemons can thin but cannot return the absolute + // snapshot sequence needed to recover a gap. Clear their stale hint too. + const safeBackground = this.supportsAuthoritativeBufferSnapshots && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - // Why: sleep/exact-stop must preserve restorable terminal history, - // so force a final checkpoint before killing the daemon session. + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { + if (this.checkpointInFlight) { + await this.checkpointInFlight + } await this.checkpointSessions([id], { final: true, teardown: true }) + const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + this.sleepRestoreSessionIds.add(id) + } } await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) - this.coldRestoreCache.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } // Why: the !keepHistory close path doesn't take a final checkpoint, so a // session stranded in sessionsNeedingFullCheckpoint would never be cleared. // (Under keepHistory the final checkpoint above already cleared the flag, so @@ -435,12 +531,30 @@ export class DaemonPtyAdapter implements IPtyProvider { ackColdRestore(sessionId: string): void { this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) } clearTombstone(sessionId: string): void { this.killedSessionTombstones.delete(sessionId) } + private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer + // (vim/less/htop); normal sessions use the full snapshot + rehydrate. + // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) + // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* + // (no rehydrateSequences — they start with \x1b[?1049h, which the + // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as + // normal scrollback. An empty snapshot still yields null → no-op. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + } + async sendSignal(id: string, signal: string): Promise { await this.client.request('signal', { sessionId: id, signal }) } @@ -478,6 +592,44 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons have no absolute output sequence. Their snapshot + // cannot safely reconcile stream bytes still queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } catch { + return null + } + } + async clearBuffer(id: string): Promise { await this.client.request('clearScrollback', { sessionId: id }) this.markSessionDirty(id) @@ -608,6 +760,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.sessionsNeedingFullCheckpoint.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.stopCheckpointTimer() for (const id of ids) { this.coldRestoreCache.delete(id) @@ -640,7 +794,9 @@ export class DaemonPtyAdapter implements IPtyProvider { return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -650,6 +806,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } @@ -669,6 +835,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side in TerminalHost.dispose() @@ -705,6 +873,13 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a + // pause left behind would block their shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() @@ -712,8 +887,36 @@ export class DaemonPtyAdapter implements IPtyProvider { private async ensureConnected(): Promise { await this.client.ensureConnected() + // Why sampled before setupEventRouting: routing is (re)installed exactly + // once per connection, so "no listener yet" identifies a fresh connect — + // the only time the daemon-side backgrounded set needs a resync (it is + // process state that died with the previous daemon/socket). + const isFreshConnection = this.removeEventListener === null this.setupEventRouting() this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + private resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + private flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming a session the fresh daemon doesn't know is a harmless + // no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() } private stopCheckpointTimer(): void { @@ -1027,6 +1230,13 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise { console.warn(message) this.removeEventListener?.() @@ -1050,12 +1260,50 @@ export class DaemonPtyAdapter implements IPtyProvider { this.markSessionDirty(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.dataListeners]) { - listener({ id: event.sessionId, data: event.payload.data }) + listener({ + id: event.sessionId, + data: event.payload.data, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) } + } else if (event.event === 'sessionBackgroundMarker') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'backgroundMarker', + background: event.payload.background, + ...(event.payload.scanSeedAnsi !== undefined + ? { scanSeedAnsi: event.payload.scanSeedAnsi } + : {}) + }) + } else if (event.event === 'dataGap') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'dataGap', + droppedChars: event.payload.droppedChars, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) + } else if (event.event === 'transientFact') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'transientFact', + fact: event.payload + }) } else if (event.event === 'exit') { this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - this.coldRestoreCache.delete(event.sessionId) + // Why: an exited session must not be owed a resume on reconnect — a + // reused sessionId would receive a stray resumePty. Same for the + // background set: a reused id must start un-thinned. + this.pausedProducerSessionIds.delete(event.sessionId) + this.producerResumesOwedOnReconnect.delete(event.sessionId) + this.backgroundedSessionIds.delete(event.sessionId) + if (!this.sleepRestoreSessionIds.has(event.sessionId)) { + this.coldRestoreCache.delete(event.sessionId) + } // Why: an exited session can never be checkpointed again, so its pending // full-checkpoint flag is dead state. Without this, a cold-restored // session that exits before its first checkpoint leaks a permanent entry. diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 47d44aa807c..a8c2b2232d8 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void emitExit: (id: string, code: number) => void } @@ -24,7 +25,9 @@ function createAdapter( reconcileResult?: { alive: string[]; killed: string[] } ): AdapterMock { const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { @@ -44,6 +47,8 @@ function createAdapter( writes.push({ id, data }) }), resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), shutdown: vi.fn(async (id: string) => { const idx = sessions.indexOf(id) if (idx !== -1) { @@ -62,12 +67,23 @@ function createAdapter( revive: vi.fn(async () => {}), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) return () => { - const idx = dataListeners.indexOf(callback) + const idx = backgroundListeners.indexOf(callback) if (idx !== -1) { - dataListeners.splice(idx, 1) + backgroundListeners.splice(idx, 1) } } }), @@ -85,9 +101,14 @@ function createAdapter( reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), dispose: vi.fn(), disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) } }, emitExit: (id: string, code: number) => { @@ -118,6 +139,62 @@ describe('DaemonPtyRouter', () => { expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes background hints and authoritative snapshots to the session owner', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const snapshot = { + data: 'legacy frame', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + vi.mocked(legacy.getBufferSnapshot).mockResolvedValue(snapshot) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + router.setPtyBackgrounded('legacy-session', true) + await expect( + router.getBufferSnapshot('legacy-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + + expect(legacy.setPtyBackgrounded).toHaveBeenCalledWith('legacy-session', true) + expect(current.setPtyBackgrounded).not.toHaveBeenCalled() + expect(legacy.getBufferSnapshot).toHaveBeenCalledWith('legacy-session', { + scrollbackRows: 50_000 + }) + }) + + it('forwards gap events and explicit sequence accounting from every adapter', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const dataSpy = vi.fn() + const backgroundSpy = vi.fn() + router.onData(dataSpy) + router.onBackgroundStreamEvent(backgroundSpy) + + current.emitData('current-session', '\x1b[6n', 0) + legacy.emitBackground({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'current-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + expect(backgroundSpy).toHaveBeenCalledWith({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + }) + it('drops a legacy mapping after the routed session exits', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 1f80f770cd9..ed43c663325 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,6 +1,8 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -11,7 +13,11 @@ export class DaemonPtyRouter implements IPtyProvider { private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { @@ -76,6 +82,18 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.adapterFor(id).pauseProducer(id) + } + + resumeProducer(id: string): void { + this.adapterFor(id).resumeProducer(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.adapterFor(id).setPtyBackgrounded(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.adapterFor(id).shutdown(id, opts) // Why: sleep passes keepHistory=true and re-spawns against the same @@ -105,6 +123,13 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + return await this.adapterFor(id).getBufferSnapshot(id, opts) + } + async clearBuffer(id: string): Promise { await this.adapterFor(id).clearBuffer(id) } @@ -144,7 +169,9 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -154,6 +181,17 @@ export class DaemonPtyRouter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allAdapters().map((adapter) => + adapter.onBackgroundStreamEvent(callback) + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ae017e0ef02..7de928d7543 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -408,6 +408,72 @@ describe('DaemonServer', () => { vi.useRealTimers() } }) + + it('keeps exit behind final output held by the shallow socket gate', async () => { + vi.useFakeTimers() + try { + let subprocess: ReturnType + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + subprocess = createMockSubprocess() + return subprocess + } + }) + const daemon = server as unknown as DaemonServerPrivate + const refillCallbacks: (() => void)[] = [] + const controlSocket = { destroy: vi.fn() } as unknown as Socket + const streamSocket = { + destroyed: false, + destroy: vi.fn(), + writableLength: 128 * 1024, + write: vi.fn((_line: string, callback?: () => void) => { + if (callback) { + refillCallbacks.push(callback) + } + return true + }) + } as unknown as Socket & { + write: ReturnType + writableLength: number + } + + daemon.clients.set('client-1', { + clientId: 'client-1', + controlSocket, + streamSocket + }) + await daemon.routeRequest('client-1', { + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'test-session', cols: 80, rows: 24 } + }) + + const finalOutput = 'final-output'.repeat(1024) + subprocess!._simulateData(finalOutput) + subprocess!._simulateExit(42) + + // Only the refill sentinel may enter the already-deep socket; exit + // remains queued behind the final data for this session. + expect(refillCallbacks).toHaveLength(1) + const beforeRefill = streamSocket.write.mock.calls.map(([line]) => JSON.parse(String(line))) + expect(beforeRefill).toHaveLength(1) + expect(beforeRefill[0]).toMatchObject({ event: 'data', payload: { data: '' } }) + + streamSocket.writableLength = 0 + refillCallbacks[0]() + const delivered = streamSocket.write.mock.calls + .map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + .filter((message) => message.payload.data !== '') + expect(delivered.map((message) => message.event)).toEqual(['data', 'exit']) + expect(delivered[0]?.payload.data).toBe(finalOutput) + } finally { + vi.useRealTimers() + } + }) }) describe('authentication', () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7d98babfdee..7c604341c2c 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -9,6 +9,15 @@ import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { + BackgroundTransientFactRelay, + BACKGROUND_STREAM_DROP_ENABLED +} from './daemon-background-transient-facts' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + recordDaemonStreamBacklogEvent, + startDaemonStreamBacklogProbe +} from './daemon-stream-backlog-probe' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' @@ -53,8 +62,40 @@ export class DaemonServer { private log: DaemonFileLog private clients = new Map() - private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) + private streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.clients.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + // Fact scan authority for backgrounded sessions — facts ride the stream + // queue as control entries so they hold byte order with the data around + // them (a fact jumping the queue could arrive after the reveal snapshot + // that already reflects it). + private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.streamClientIdBySessionId.get(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + private streamClientIdBySessionId = new Map() private lastInputAtBySessionId = new Map() + private stopStreamBacklogProbe: () => void = () => {} // Why: main-process PTY IPC has the same recent-input bypass, but daemon // output reaches main only after this stream layer. Keeping the window here @@ -69,6 +110,14 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ + clients: Array.from(this.clients.values(), (client) => ({ + clientId: client.clientId, + socketBufferedBytes: client.streamSocket?.writableLength ?? 0, + batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) + })), + backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() + })) this.log = opts.log ?? createNoopDaemonFileLog() } @@ -97,6 +146,8 @@ export class DaemonServer { } async shutdown(): Promise { + this.stopStreamBacklogProbe() + this.transientFactRelay.dispose() this.host.dispose() this.streamDataBatcher.clear() @@ -225,6 +276,10 @@ export class DaemonServer { const previous = client.streamSocket socket.removeAllListeners('data') client.streamSocket = socket + // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. + socket.on('drain', () => { + this.streamDataBatcher.flush(client.clientId) + }) const cleanup = (): void => { socket.removeListener('close', cleanup) @@ -296,6 +351,9 @@ export class DaemonServer { : {}), streamClient: { onData: (data) => { + // Scan BEFORE enqueue: the batcher may keep-tail drop this + // chunk, but its facts must be captured regardless. + this.transientFactRelay.onSessionData(p.sessionId, data) const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) const isInteractiveOutput = data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && @@ -307,24 +365,38 @@ export class DaemonServer { }) }, onExit: (code) => { - // Why: exit tears down renderer handlers; flush final output first - // so the last few milliseconds of PTY data are not stranded. + // Why: exit tears down renderer handlers, so it must ride the + // ordered queue behind final output even when the shallow socket + // gate holds that output for a later drain pass. this.log.log('session-exited', { sessionId: p.sessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) this.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: p.sessionId.slice(-10) + }) + this.transientFactRelay.onSessionExit(p.sessionId) + this.streamClientIdBySessionId.delete(p.sessionId) this.lastInputAtBySessionId.delete(p.sessionId) - if (client?.streamSocket) { - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId: p.sessionId, - payload: { code } - }) - ) - } } } }) + this.streamClientIdBySessionId.set(p.sessionId, clientId) + // Why an attach-time marker: the adapter resyncs the background set on + // a fresh connection, which can precede this attach — main's scan + // suppression must still start at the head of the new stream. + if (this.transientFactRelay.isBackgrounded(p.sessionId)) { + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: p.sessionId, + payload: { background: true } + }) + } this.log.log(result.isNew ? 'session-created' : 'session-attached', { sessionId: p.sessionId, pid: result.pid @@ -365,6 +437,56 @@ export class DaemonServer { } return {} + case 'pausePty': + this.host.pauseProducer(request.payload.sessionId) + return {} + + case 'resumePty': + this.host.resumeProducer(request.payload.sessionId) + return {} + + case 'setSessionBackground': { + const sessionId = request.payload.sessionId + const background = request.payload.background === true + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + if (!this.transientFactRelay.setSessionBackground(sessionId, background)) { + return {} + } + if (background) { + // Prime the fresh relay tracker with the emulator's dangling + // incomplete escape so a sequence split across the handoff parses + // exactly as if the relay had seen the whole stream. + this.transientFactRelay.seedSessionScanState( + sessionId, + this.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.streamClientIdBySessionId.get(sessionId) + if (!streamClientId) { + // Not attached yet — the attach-time marker covers the handoff. + return {} + } + // Reveal deliberately does NOT discard or force-flush the queued + // tail: main's model (hidden-output recovery buffer, tail previews) + // needs those bytes — a finished program's last output lives there — + // and the normal flush/drain loop delivers them within milliseconds + // (bounded ≤ the keep-tail drop cap), in order, ahead of the marker. + const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) + this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}) + } + }) + return {} + } + case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) this.log.log('session-killed', { @@ -397,8 +519,26 @@ export class DaemonServer { case 'listSessions': return { sessions: this.host.listSessions() } - case 'getSnapshot': - return { snapshot: this.host.getSnapshot(request.payload.sessionId) } + case 'getSnapshot': { + const snapshotStart = performance.now() + const requestedScrollbackRows = request.payload.scrollbackRows + const scrollbackRows = + typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) + : undefined + const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - snapshotStart + if (snapshotMs >= 25) { + // Serialize stalls block the daemon's single thread — every pty's + // echo included. Surfaced here so multi-second typing stalls can be + // attributed to checkpoint storms (issue #5096 family) in the field. + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: request.payload.sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } case 'getSize': return { size: this.host.getAppliedSize(request.payload.sessionId) } @@ -449,13 +589,12 @@ export class DaemonServer { // Why: write/resize are notification-heavy and intentionally do not wait // for replies. If their target session is gone, this synthetic exit is the // only signal the renderer gets to clear stale terminal pane bindings. - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId, - payload: { code } - }) - ) + this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code } + }) + this.streamDataBatcher.flush(client.clientId) } } diff --git a/src/main/daemon/daemon-stream-backlog-probe.ts b/src/main/daemon/daemon-stream-backlog-probe.ts new file mode 100644 index 00000000000..444d1deb250 --- /dev/null +++ b/src/main/daemon/daemon-stream-backlog-probe.ts @@ -0,0 +1,55 @@ +/** + * Env-gated diagnostics for the daemon→main stream backlog: samples the + * batcher queue and each stream socket's user-space write buffer so + * multi-second echo lag can be attributed to the hop that actually holds the + * bytes. Enable by setting ORCA_DAEMON_STREAM_BACKLOG_FILE to a writable + * path; zero cost otherwise. The timer only observes and appends JSONL — it + * never mutates delivery state. + */ +import { appendFileSync } from 'node:fs' + +export type StreamBacklogClientSample = { + clientId: string + socketBufferedBytes: number + batcherQueuedChars: number +} + +export type StreamBacklogSample = { + clients: StreamBacklogClientSample[] + backgroundedSessionIdSuffixes?: string[] +} + +const SAMPLE_INTERVAL_MS = 250 + +/** Event-level entries interleaved with the periodic samples — used to + * attribute WHO mutated pacing state, not just when counts changed. */ +export function recordDaemonStreamBacklogEvent( + event: string, + detail: Record +): void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return + } + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), event, ...detail })}\n`) + } catch { + // Diagnostics must never break the daemon. + } +} + +export function startDaemonStreamBacklogProbe(sample: () => StreamBacklogSample): () => void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return () => {} + } + const timer = setInterval(() => { + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), ...sample() })}\n`) + } catch { + // Diagnostics must never break the daemon. + } + }, SAMPLE_INTERVAL_MS) + timer.unref?.() + return () => clearInterval(timer) +} diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 8777434e1d9..32d85ff87b0 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -6,12 +6,37 @@ import { createNdjsonParser } from './ndjson' function createBatcher(options?: ConstructorParameters[1]) { const streamSocket = { destroyed: false, + writableLength: 0, write: vi.fn() - } as unknown as Socket & { write: ReturnType } + } as unknown as Socket & { write: ReturnType; writableLength: number } const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options) return { batcher, streamSocket } } +function writtenData(streamSocket: { write: ReturnType }): string { + return streamSocket.write.mock.calls + .map(([line]) => { + const parsed = JSON.parse(String(line)) as { payload?: { data?: string } } + return parsed.payload?.data ?? '' + }) + .join('') +} + +type ParsedWrite = { + event: string + sessionId?: string + payload?: { data?: string } +} + +// A held pass arms a zero-payload data event whose kernel-flush callback +// refills the queue; it carries no content, so assertions about delivered +// output must ignore it. +function nonSentinelWrites(streamSocket: { write: ReturnType }): ParsedWrite[] { + return streamSocket.write.mock.calls + .map(([line]) => JSON.parse(String(line)) as ParsedWrite) + .filter((message) => !(message.event === 'data' && (message.payload?.data ?? '') === '')) +} + describe('DaemonStreamDataBatcher', () => { it('coalesces background output before writing daemon stream events', () => { vi.useFakeTimers() @@ -22,7 +47,7 @@ describe('DaemonStreamDataBatcher', () => { batcher.enqueue('client-1', 'session-1', 'b') expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(streamSocket.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) @@ -45,7 +70,7 @@ describe('DaemonStreamDataBatcher', () => { expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('\\u001b[20;2Hredraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -65,7 +90,7 @@ describe('DaemonStreamDataBatcher', () => { }) expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain(`${pending}redraw`) } finally { @@ -91,7 +116,7 @@ describe('DaemonStreamDataBatcher', () => { ) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(2) expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain( '"sessionId":"session-background"' @@ -102,6 +127,447 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('holds bulk output while the socket buffer is deep and resumes on the next flush', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'x'.repeat(64 * 1024) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + expect(batcher.queuedCharsForClient('client-1')).toBe(bulk.length) + + // Socket drained (server routes 'drain' to flush): held bulk writes. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(bulk) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('lets interactive echo jump bulk held behind a deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + batcher.enqueue('client-1', 'session-typing', 'echo', { + flushImmediately: true, + flushMaxChars: 1024 + }) + const written = nonSentinelWrites(streamSocket) + expect(written).toHaveLength(1) + expect(written[0]?.sessionId).toBe('session-typing') + // The bulk stays held — order across sessions has no contract, and the + // deep socket is exactly what the echo must not queue behind. + expect(batcher.queuedCharsForClient('client-1')).toBe(64 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('lets a small session write through the gate while a flooding session holds (per-session fairness)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-flood', 'x'.repeat(256 * 1024)) + // Non-interactive small output (echo that missed the 100ms fast-path). + batcher.enqueue('client-1', 'session-typing', 'echo-line') + vi.advanceTimersByTime(2) + + // The flood holds; the tiny session's bytes must NOT wait behind it. + const written = nonSentinelWrites(streamSocket) + expect(written.some((m) => m.sessionId === 'session-typing')).toBe(true) + expect(writtenData(streamSocket)).toContain('echo-line') + expect(written.some((m) => m.sessionId === 'session-flood')).toBe(false) + expect(batcher.queuedCharsForClient('client-1')).toBe(256 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('never reorders bytes within a session around the small-session bypass', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + // Same session: big entry then (non-adjacent) small entry. + batcher.enqueue('client-1', 'session-a', 'x'.repeat(256 * 1024)) + batcher.enqueue('client-1', 'session-b', 'other') + batcher.enqueue('client-1', 'session-a', 'tail') + vi.advanceTimersByTime(2) + const written = streamSocket.write.mock.calls.map(([line]) => String(line)).join('') + // session-a held its first entry, so its tail must be held too. + expect(written).not.toContain('tail') + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toContain('x'.repeat(64)) + // Full reassembly, in order, once drained. + const aPayload = streamSocket.write.mock.calls + .map( + ([line]) => + JSON.parse(String(line)) as { sessionId?: string; payload?: { data?: string } } + ) + .filter((m) => m.sessionId === 'session-a') + .map((m) => m.payload?.data ?? '') + .join('') + expect(aPayload).toBe(`${'x'.repeat(256 * 1024)}tail`) + } finally { + vi.useRealTimers() + } + }) + + it('slices oversized held entries so one write cannot re-deepen the socket unboundedly', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'y'.repeat(64 * 1024 + 5) + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + // Two slices: 64K then the 5-char remainder; payload reassembles intact. + expect(streamSocket.write).toHaveBeenCalledTimes(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('stops mid-queue when a written slice is followed by a still-deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const first = 'a'.repeat(70 * 1024) + + batcher.enqueue('client-1', 'session-bulk', first) + // First slice write fills the socket past the gate; the remainder holds. + streamSocket.write.mockImplementation(() => { + streamSocket.writableLength = 200 * 1024 + return false + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(1) + expect(batcher.queuedCharsForClient('client-1')).toBe(first.length - 64 * 1024) + + streamSocket.writableLength = 0 + streamSocket.write.mockImplementation(() => true) + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(first) + } finally { + vi.useRealTimers() + } + }) + + it('writes through the gate once held bulk exceeds the memory safety valve', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const huge = 'z'.repeat(32 * 1024 * 1024 + 1) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', huge) + vi.advanceTimersByTime(2) + // Deep socket, but holding would exceed the valve: old write-through + // behavior wins over bounded echo latency. + expect(writtenData(streamSocket).length).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not split surrogate pairs at the bulk slice boundary', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + // Position an astral char to straddle the 64K slice boundary. + const bulk = `${'a'.repeat(64 * 1024 - 1)}😀${'b'.repeat(10)}` + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + for (const [line] of streamSocket.write.mock.calls) { + expect(String(line)).not.toContain('�') + } + } finally { + vi.useRealTimers() + } + }) + + it('keep-tail drops a droppable session over the cap and delivers a gap before the kept tail', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: (sessionId) => sessionId === 'session-bg' + }) + // Two enqueues that together cross the 1MB cap. + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(900 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(300 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(512 * 1024) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + expect(messages[0]?.payload.droppedChars).toBe((900 + 300 - 512) * 1024) + const delivered = messages + .filter((m) => m.event === 'data') + .map((m) => m.payload.data ?? '') + .join('') + expect(delivered.length).toBe(512 * 1024) + // Keep-TAIL: the newest bytes survive. + expect(delivered.endsWith('b'.repeat(300 * 1024))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('accounts every dropped char across repeated drops (gap sums + delivered = enqueued)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + // Deep socket: data holds; a gap entry (~100B) may still write through — + // it always precedes the kept tail, so a second drop mints a second gap. + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(1024 * 1024 + 1)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(600 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'c'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + const gapChars = messages + .filter((m) => m.event === 'dataGap') + .reduce((sum, m) => sum + (m.payload.droppedChars ?? 0), 0) + const dataMessages = messages.filter((m) => m.event === 'data') + const deliveredChars = dataMessages.reduce((sum, m) => sum + (m.payload.data?.length ?? 0), 0) + expect(deliveredChars).toBeLessThanOrEqual(1024 * 1024) + expect(gapChars + deliveredChars).toBe(1024 * 1024 + 1 + 1200 * 1024) + // The newest bytes always survive. + expect(dataMessages.at(-1)?.payload.data?.endsWith('c')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('salvages reply-eliciting query bytes out of dropped data', () => { + vi.useFakeTimers() + try { + const dsr = '\x1b[6n' + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: () => true, + salvageDroppedData: (dropped) => (dropped.includes(dsr) ? dsr : '') + }) + // The DSR probe sits in the oldest (dropped) region. + batcher.enqueue('client-1', 'session-bg', `flood${dsr}${'x'.repeat(900 * 1024)}`) + batcher.enqueue('client-1', 'session-bg', 'y'.repeat(300 * 1024)) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number; sequenceChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + // The salvaged query rides right after the gap, before the kept tail. + expect(messages[1]?.event).toBe('data') + expect(messages[1]?.payload.data).toBe(dsr) + expect(messages[1]?.payload.sequenceChars).toBe(0) + const originalSequenceChars = messages.reduce( + (sum, message) => + sum + + (message.event === 'dataGap' + ? (message.payload.sequenceChars ?? 0) + : (message.payload.sequenceChars ?? message.payload.data?.length ?? 0)), + 0 + ) + expect(originalSequenceChars).toBe(`flood${dsr}${'x'.repeat(900 * 1024)}`.length + 300 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('shrinks keep-tails as more backgrounded sessions queue (global aggregate budget)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + streamSocket.writableLength = 128 * 1024 // deep socket: queues accumulate + // 17 backgrounded sessions × 768KB — each below the single-session cap, + // but a reveal would have to drain the ~13MB aggregate (measured 2.5s + // hidden-restore). The global budget thins each to ~2MB/17 ≈ 120KB. + for (let s = 0; s < 17; s++) { + batcher.enqueue('client-1', `session-${s}`, '#'.repeat(768 * 1024)) + } + const totalQueued = batcher.queuedCharsForClient('client-1') + expect(totalQueued).toBeLessThan(3 * 1024 * 1024) + // Every session still keeps at least a full screen of newest tail. + streamSocket.writableLength = 0 + batcher.flush('client-1') + const perSession = new Map() + for (const m of nonSentinelWrites(streamSocket)) { + if (m.event === 'data' && m.sessionId) { + perSession.set( + m.sessionId, + (perSession.get(m.sessionId) ?? 0) + (m.payload?.data?.length ?? 0) + ) + } + } + for (let s = 0; s < 17; s++) { + expect(perSession.get(`session-${s}`) ?? 0).toBeGreaterThanOrEqual(64 * 1024) + } + } finally { + vi.useRealTimers() + } + }) + + it('never drops sessions that are not droppable', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => false }) + const bulk = 'v'.repeat(2 * 1024 * 1024) + batcher.enqueue('client-1', 'session-visible', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('delivers control events in byte order with the session data around them', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + batcher.enqueue('client-1', 'session-1', 'before') + batcher.enqueueControlEvent('client-1', 'session-1', { + type: 'event', + event: 'transientFact', + sessionId: 'session-1', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-1', 'after') + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages[0]?.payload.data).toBe('before') + expect(messages[2]?.payload.data).toBe('after') + } finally { + vi.useRealTimers() + } + }) + + it("holds a control event behind its session's held bulk (order latch)", () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bulk', { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: 'session-bulk', + payload: { background: true } + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(nonSentinelWrites(streamSocket).map((m) => m.event)).toEqual([ + 'data', + 'sessionBackgroundMarker' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('delivers a held queued tail (data + facts, in order) once the socket drains', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bg', { + type: 'event', + event: 'transientFact', + sessionId: 'session-bg', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-bg', 'DONE') + vi.advanceTimersByTime(2) + + // Reveal never discards: a finished program's last output must reach + // main's model (restore reads it). The normal drain loop delivers it. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + const messages = nonSentinelWrites(streamSocket) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages.at(-1)?.payload?.data).toBe('DONE') + } finally { + vi.useRealTimers() + } + }) + + it('arms one kernel-flush refill sentinel per held pass and resumes without waiting for drain', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const flushCallbacks: (() => void)[] = [] + streamSocket.write.mockImplementation((_line: string, cb?: () => void) => { + if (cb) { + flushCallbacks.push(cb) + } + return true + }) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + // Held pass → exactly one sentinel armed; further held passes don't stack. + expect(flushCallbacks).toHaveLength(1) + batcher.flush('client-1') + expect(flushCallbacks).toHaveLength(1) + + // Kernel flushed the in-flight bytes (socket now shallow): the sentinel + // callback resumes the held bulk with no 'drain' event involved. + streamSocket.writableLength = 0 + flushCallbacks[0]() + expect(writtenData(streamSocket)).toBe('x'.repeat(64 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('writes large stream data as parser-sized NDJSON events', () => { vi.useFakeTimers() try { @@ -113,7 +579,7 @@ describe('DaemonStreamDataBatcher', () => { const parser = createNdjsonParser(onMessage, onError, { maxLineBytes }) batcher.enqueue('client-1', 'session-1', data) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (const [line] of streamSocket.write.mock.calls) { parser.feed(String(line)) } diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 3374222f17f..84f7465b913 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -1,19 +1,63 @@ import type { Socket } from 'node:net' import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import { + clampToSafeSplitIndex, + encodeStreamDataEvent, + writeStreamDataEvents +} from './daemon-stream-data-split' +import { + backgroundSessionDropCapChars, + backgroundSessionKeepTailChars, + dropOldestQueuedForSession, + type PendingStreamDataBatch +} from './daemon-stream-keep-tail-drop' +import type { DaemonEvent } from './types' type StreamDataClient = { streamSocket: Socket | null } -type PendingStreamDataBatch = { - timer: ReturnType | null - queue: { sessionId: string; data: string }[] - queuedChars: number -} +// Why 2ms: under continuous agent output every chunk waits an expected +// half-window here AND again in main's PTY batch — at 8ms each that was +// ~8ms of the measured ~19ms DSR-under-load latency. 2ms keeps burst +// coalescing (~500 socket writes/s worst case, ~100B framing overhead per +// write against MB/s payloads) while cutting the fixed latency tax 4x. +const STREAM_DATA_BATCH_INTERVAL_MS = 2 -// Why: match main-process PTY IPC batching to avoid adding latency while -// removing daemon socket writes and JSON framing during bursty output. -const STREAM_DATA_BATCH_INTERVAL_MS = 8 +// Why a shallow socket: the stream socket is one FIFO for every session, and +// bytes already written can never be overtaken — a deep user-space buffer +// buries a visible pane's keystroke echo behind bulk output for other panes +// (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at +// this depth and the remainder is HELD here, where the interactive +// flushSession path can still jump it; socket 'drain' refills. Echo latency +// is then bounded by the shallow depth, not by how much bulk is in flight. +// 128KB must stay above the socket's ~16KB highWaterMark so a held state +// implies a false write() and therefore a guaranteed 'drain' wake-up. +// Kill switch: ORCA_DAEMON_SHALLOW_SOCKET_GATE=0 restores pre-gate unbounded +// socket writes for field debugging and true fix-off A/B benches. +const SHALLOW_SOCKET_WRITE_GATE_BYTES = + process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024 +// Why sliced writes: enqueue coalesces per-session entries, so a held entry +// can grow to megabytes; writing it whole would re-deepen the socket past the +// gate in one call. +const BULK_WRITE_SLICE_CHARS = 64 * 1024 +// Safety valve: if held bulk ever exceeds this, write through to the socket +// (exactly the pre-gate behavior) — bounded daemon memory beats bounded echo +// latency in the extreme. Must sit FAR above the pacer's pause watermark plus +// its overshoot (observed ~5MB with 17 paused sessions' in-flight pty reads): +// an engaged valve deepens the socket and buries interactive echo behind the +// whole backlog (measured as bimodal ~2.4s key medians when this was 8MB). +const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 +// Why a small-session bypass: the hold is there to stop FLOODS from burying +// everyone else; a session with only a few KB queued (keystroke echo, prompt +// redraws, query replies) is never the flood and must not wait FIFO behind +// other sessions' megabytes. The daemon's 100ms interactive fast-path is a +// heuristic that misses under event-loop load (measured: echo classified +// non-interactive rode the held queue for ~2.4s); this bypass is the +// deterministic backstop. Worst socket over-deepening per flush is +// sessions × this ≈ tens of KB. +const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 type EnqueueOptions = { flushImmediately?: boolean @@ -22,90 +66,25 @@ type EnqueueOptions = { type DaemonStreamDataBatcherOptions = { maxLineBytes?: number -} - -function encodeStreamDataEvent(sessionId: string, data: string): string { - return encodeNdjson({ - type: 'event', - event: 'data', - sessionId, - payload: { data } - }) -} - -function streamDataEventLineBytes(sessionId: string, data: string): number { - return Buffer.byteLength(encodeStreamDataEvent(sessionId, data), 'utf8') -} - -function isHighSurrogate(value: number): boolean { - return value >= 0xd800 && value <= 0xdbff -} - -function isLowSurrogate(value: number): boolean { - return value >= 0xdc00 && value <= 0xdfff -} - -function clampToSafeSplitIndex(value: string, start: number, end: number): number { - if (end <= start || end >= value.length) { - return end - } - const prev = value.charCodeAt(end - 1) - const next = value.charCodeAt(end) - return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end -} - -function nextSafeSplitIndex(value: string, start: number): number { - const next = Math.min(value.length, start + 1) - if ( - next < value.length && - isHighSurrogate(value.charCodeAt(start)) && - isLowSurrogate(value.charCodeAt(next)) - ) { - return next + 1 - } - return next -} - -function splitStreamDataForNdjson(sessionId: string, data: string, maxLineBytes: number): string[] { - if (streamDataEventLineBytes(sessionId, data) <= maxLineBytes) { - return [data] - } - - const chunks: string[] = [] - let start = 0 - while (start < data.length) { - let low = start + 1 - let high = data.length - let best = start - - while (low <= high) { - const rawMid = Math.floor((low + high) / 2) - const mid = clampToSafeSplitIndex(data, start, rawMid) - if (mid <= start) { - low = rawMid + 1 - continue - } - - if (streamDataEventLineBytes(sessionId, data.slice(start, mid)) <= maxLineBytes) { - best = mid - low = rawMid + 1 - } else { - high = rawMid - 1 - } - } - - const end = best > start ? best : nextSafeSplitIndex(data, start) - chunks.push(data.slice(start, end)) - start = end - } - - return chunks + /** Fires after each stream-socket write — the only place backlog grows, so + * the backlog pacer checks its watermark here. */ + onAfterSocketWrite?: () => void + /** True for sessions whose queued output may be keep-tail dropped + * (main-marked background sessions). */ + isSessionDroppable?: (sessionId: string) => boolean + /** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC color probes) out + * of dropped data — the hidden program blocks on the reply, so those few + * bytes must still be delivered even when their flood is not. */ + salvageDroppedData?: (dropped: string) => string } export class DaemonStreamDataBatcher { private pendingByClient = new Map() private getClient: (clientId: string) => StreamDataClient | undefined private maxLineBytes: number + private onAfterSocketWrite: (() => void) | undefined + private isSessionDroppable: (sessionId: string) => boolean + private salvageDroppedData: (dropped: string) => string constructor( getClient: (clientId: string) => StreamDataClient | undefined, @@ -113,6 +92,9 @@ export class DaemonStreamDataBatcher { ) { this.getClient = getClient this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) + this.onAfterSocketWrite = options.onAfterSocketWrite + this.isSessionDroppable = options.isSessionDroppable ?? (() => false) + this.salvageDroppedData = options.salvageDroppedData ?? (() => '') } enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void { @@ -121,19 +103,45 @@ export class DaemonStreamDataBatcher { return } - let batch = this.pendingByClient.get(clientId) - if (!batch) { - batch = { timer: null, queue: [], queuedChars: 0 } - this.pendingByClient.set(clientId, batch) - } - + const batch = this.getOrCreateBatch(clientId) const last = batch.queue.at(-1) - if (last?.sessionId === sessionId) { + // Never coalesce across a control entry — it marks a position in the + // session's byte order. + if (last?.sessionId === sessionId && !last.control) { last.data += data } else { batch.queue.push({ sessionId, data }) } batch.queuedChars += data.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length + ) + + if (this.isSessionDroppable(sessionId)) { + // Keep-tail scales down as more backgrounded sessions queue, bounding + // the AGGREGATE a reveal must drain (see daemon-stream-keep-tail-drop). + const droppableQueued = this.countDroppableSessionsWithQueuedData(batch) + const dropCap = backgroundSessionDropCapChars(droppableQueued) + const keepTail = backgroundSessionKeepTailChars(droppableQueued) + if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > dropCap) { + dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData) + } + if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) { + // The shared budget tightened: re-trim sessions that already finished + // producing — they never re-enter this path on their own. + for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) { + if ( + queued > dropCap && + queuedSessionId !== sessionId && + this.isSessionDroppable(queuedSessionId) + ) { + dropOldestQueuedForSession(batch, queuedSessionId, keepTail, this.salvageDroppedData) + } + } + } + batch.lastDroppableSessionCount = droppableQueued + } if ( options.flushImmediately === true && @@ -148,6 +156,43 @@ export class DaemonStreamDataBatcher { } } + /** Append a pre-shaped stream event at the current position in the + * session's byte order (scan handoff markers, gaps, transient facts). */ + enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void { + const client = this.getClient(clientId) + if (!client?.streamSocket || client.streamSocket.destroyed) { + return + } + const batch = this.getOrCreateBatch(clientId) + batch.queue.push({ sessionId, data: '', control }) + if (!batch.timer) { + batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS) + } + } + + private countDroppableSessionsWithQueuedData(batch: PendingStreamDataBatch): number { + let count = 0 + for (const [sessionId, queued] of batch.queuedCharsBySession) { + if (queued > 0 && this.isSessionDroppable(sessionId)) { + count++ + } + } + return count + } + + private getOrCreateBatch(clientId: string): PendingStreamDataBatch { + let batch = this.pendingByClient.get(clientId) + if (!batch) { + batch = { timer: null, queue: [], queuedChars: 0, queuedCharsBySession: new Map() } + this.pendingByClient.set(clientId, batch) + } + return batch + } + + queuedCharsForClient(clientId: string): number { + return this.pendingByClient.get(clientId)?.queuedChars ?? 0 + } + flush(clientId: string): void { const batch = this.pendingByClient.get(clientId) if (!batch) { @@ -158,16 +203,113 @@ export class DaemonStreamDataBatcher { clearTimeout(batch.timer) batch.timer = null } - this.pendingByClient.delete(clientId) const client = this.getClient(clientId) if (!client?.streamSocket || client.streamSocket.destroyed) { + // Same as pre-gate behavior: a vanished stream socket drops the batch — + // the model owns the bytes and reconnect restores from a snapshot. + this.pendingByClient.delete(clientId) return } - for (const entry of batch.queue) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + const socket = client.streamSocket + // Sessions that held an entry must hold ALL their later entries in this + // pass — writing around a held entry would reorder that session's bytes. + const heldSessions = new Set() + const retained: PendingStreamDataBatch['queue'] = [] + while (batch.queue.length > 0) { + const entry = batch.queue[0] + if (entry.control) { + // Control entries only respect the held-session order latch — they + // are ~100B, so writing them onto a deep socket is as harmless as the + // small-session bypass. + if (heldSessions.has(entry.sessionId)) { + retained.push(entry) + batch.queue.shift() + continue + } + batch.queue.shift() + socket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + continue + } + const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES + if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) { + const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0 + if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) { + // Hold this flooding session's entry; small talkers keep flowing. + // The socket's 'drain' (routed back to flush by the server) + // resumes held bulk. No timer: a deep socket implies a prior + // false write(), so 'drain' is guaranteed. + heldSessions.add(entry.sessionId) + retained.push(entry) + batch.queue.shift() + continue + } + } else if (socketDeep) { + // Valve engaged: held bulk exceeded the memory cap and is being + // written through onto a deep socket — echo protection is off until + // it drains. Rare enough to be worth a diagnostics event every time. + recordDaemonStreamBacklogEvent('heldWriteThrough', { + heldChars: batch.queuedChars, + socketBufferedBytes: socket.writableLength ?? 0 + }) + } + const end = + entry.data.length <= BULK_WRITE_SLICE_CHARS + ? entry.data.length + : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) + const slice = entry.data.slice(0, end) + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length + if (end >= entry.data.length) { + batch.queue.shift() + } else { + entry.data = entry.data.slice(end) + const remainingSequenceChars = entrySequenceChars - sliceSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + } + batch.queuedChars -= slice.length + const sessionHeldAfter = + (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length + if (sessionHeldAfter <= 0) { + batch.queuedCharsBySession.delete(entry.sessionId) + } else { + batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) + } + writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars) + this.onAfterSocketWrite?.() } + if (retained.length > 0) { + batch.queue = retained + // Held entries must not wait for the socket's 'drain' alone: drain only + // fires when the user-space buffer fully EMPTIES, so bulk would advance + // one gate-depth per daemon event-loop turn — seconds of dead time for + // a multi-MB hidden backlog on a busy daemon (measured: hidden-restore + // 2.5s vs the 1.5s budget). Arm ONE ~90B empty data event whose + // kernel-flush callback re-flushes while bytes are still in flight, so + // main never starves. (An empty socket write's callback fires + // immediately — verified — so the sentinel must be a real protocol + // no-op line.) Event-driven, no timers; the per-client latch stops + // sentinel stacking; 'drain' remains the backstop. + this.armHeldQueueRefill(socket, clientId, retained[0].sessionId) + return + } + this.pendingByClient.delete(clientId) + } + + private refillArmedClients = new Set() + + private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void { + if (this.refillArmedClients.has(clientId) || socket.destroyed) { + return + } + this.refillArmedClients.add(clientId) + socket.write(encodeStreamDataEvent(sessionId, ''), () => { + this.refillArmedClients.delete(clientId) + this.flush(clientId) + }) } private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { @@ -203,6 +345,7 @@ export class DaemonStreamDataBatcher { batch.queue = retained batch.queuedChars -= flushedChars + batch.queuedCharsBySession.delete(sessionId) if (batch.queue.length === 0) { if (batch.timer) { clearTimeout(batch.timer) @@ -217,7 +360,19 @@ export class DaemonStreamDataBatcher { } for (const entry of flushed) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + if (entry.control) { + client.streamSocket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + } else { + writeStreamDataEvents( + client.streamSocket, + entry.sessionId, + entry.data, + this.maxLineBytes, + entry.sequenceChars ?? entry.data.length + ) + this.onAfterSocketWrite?.() + } } } @@ -234,13 +389,4 @@ export class DaemonStreamDataBatcher { this.pendingByClient.delete(id) } } - - private writeStreamDataEvent(streamSocket: Socket, sessionId: string, data: string): void { - // Why: createNdjsonParser rejects oversized lines. Terminal output can - // burst faster than the batch interval, so writer-side chunking prevents - // the daemon from dropping its own stream events at the receiver. - for (const chunk of splitStreamDataForNdjson(sessionId, data, this.maxLineBytes)) { - streamSocket.write(encodeStreamDataEvent(sessionId, chunk)) - } - } } diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts new file mode 100644 index 00000000000..0e15cf49523 --- /dev/null +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -0,0 +1,114 @@ +/** + * Surrogate-safe splitting for daemon stream data events: NDJSON line-size + * chunking (the receiver's parser rejects oversized lines) and the safe-index + * clamp shared by the batcher's bulk write slicing and keep-tail dropping. + */ +import { encodeNdjson } from './ndjson' +import type { Socket } from 'node:net' + +export function encodeStreamDataEvent( + sessionId: string, + data: string, + sequenceChars?: number +): string { + return encodeNdjson({ + type: 'event', + event: 'data', + sessionId, + payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) } + }) +} + +function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8') +} + +function isHighSurrogate(value: number): boolean { + return value >= 0xd800 && value <= 0xdbff +} + +function isLowSurrogate(value: number): boolean { + return value >= 0xdc00 && value <= 0xdfff +} + +export function clampToSafeSplitIndex(value: string, start: number, end: number): number { + if (end <= start || end >= value.length) { + return end + } + const prev = value.charCodeAt(end - 1) + const next = value.charCodeAt(end) + return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end +} + +function nextSafeSplitIndex(value: string, start: number): number { + const next = Math.min(value.length, start + 1) + if ( + next < value.length && + isHighSurrogate(value.charCodeAt(start)) && + isLowSurrogate(value.charCodeAt(next)) + ) { + return next + 1 + } + return next +} + +export function splitStreamDataForNdjson( + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars?: number +): string[] { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { + return [data] + } + + const chunks: string[] = [] + let start = 0 + while (start < data.length) { + let low = start + 1 + let high = data.length + let best = start + + while (low <= high) { + const rawMid = Math.floor((low + high) / 2) + const mid = clampToSafeSplitIndex(data, start, rawMid) + if (mid <= start) { + low = rawMid + 1 + continue + } + + if ( + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes + ) { + best = mid + low = rawMid + 1 + } else { + high = rawMid - 1 + } + } + + const end = best > start ? best : nextSafeSplitIndex(data, start) + chunks.push(data.slice(start, end)) + start = end + } + + return chunks +} + +export function writeStreamDataEvents( + streamSocket: Pick, + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars = data.length +): void { + const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars + for (const chunk of splitStreamDataForNdjson( + sessionId, + data, + maxLineBytes, + explicitSequenceChars + )) { + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars)) + } +} diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts new file mode 100644 index 00000000000..1644642bd09 --- /dev/null +++ b/src/main/daemon/daemon-stream-events.ts @@ -0,0 +1,78 @@ +// ─── Events (Daemon → Client, on stream socket) ──────────────────── +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' + +export type DataEvent = { + type: 'event' + event: 'data' + sessionId: string + payload: { data: string; sequenceChars?: number } +} + +export type ExitEvent = { + type: 'event' + event: 'exit' + sessionId: string + payload: { code: number } +} + +export type TerminalErrorEvent = { + type: 'event' + event: 'terminalError' + sessionId: string + payload: { message: string } +} + +// Why these ride the stream socket (not control): each marks a POSITION in a +// session's byte stream — scan-authority handoffs and dropped ranges are only +// meaningful relative to the data events around them. Old mains ignore +// unknown stream events, and only new mains send setSessionBackground. The +// v20 bump is for sequence-safe recovery snapshots, not these tolerated events. + +/** Scan-authority handoff marker: bytes before this event were (not) scanned + * by the daemon's transient-fact relay; main flips its own scanners at + * exactly this position so no fact double-fires or goes missing. + * scanSeedAnsi (un-background only) carries the emulator's dangling + * incomplete escape so main can prime its fresh scanner carry — a sequence + * split across the handoff must not mint a phantom bell or lose its fact. */ +export type SessionBackgroundMarkerEvent = { + type: 'event' + event: 'sessionBackgroundMarker' + sessionId: string + payload: { background: boolean; scanSeedAnsi?: string } +} + +/** A backgrounded session's oldest undelivered output was dropped at the + * daemon (keep-tail thinning). The daemon emulator ingested every byte — + * only this monitoring stream is thinned. */ +export type DataGapEvent = { + type: 'event' + event: 'dataGap' + sessionId: string + payload: { droppedChars: number; sequenceChars?: number } +} + +/** Notification-bearing fact detected by the daemon while it holds scan + * authority for a backgrounded session. Title/agent-status facts stay + * main-side: they converge from the kept tail (stale-working-title timer, + * snapshot-restores-title-state) and fuse with main-fabricated synthetic + * frames the daemon never sees. */ +export type DaemonTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type TransientFactEvent = { + type: 'event' + event: 'transientFact' + sessionId: string + payload: DaemonTransientFact +} + +export type DaemonEvent = + | DataEvent + | ExitEvent + | TerminalErrorEvent + | SessionBackgroundMarkerEvent + | DataGapEvent + | TransientFactEvent diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts new file mode 100644 index 00000000000..0dc57e53522 --- /dev/null +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -0,0 +1,182 @@ +/** + * Keep-tail thinning for backgrounded sessions' queued stream data. Hidden + * panes' stream copy is a monitoring feed (tail previews, agent status) — the + * daemon emulator holds the complete model and reveal restores from its + * snapshot. Once a backgrounded session's undelivered output exceeds the cap, + * its OLDEST bytes are dropped down to the keep-tail and a dataGap event + * takes their place, so the feed stays tail-fresh, daemon memory stays + * bounded, and the producer is never paused (no reveal catch-up). + */ +import { clampToSafeSplitIndex } from './daemon-stream-data-split' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonEvent, DataGapEvent } from './types' + +// A control entry carries a whole pre-shaped stream event (background marker, +// data gap, transient fact) that must ride at its exact position in the +// session's byte order; its data is always '' so it never counts against the +// gate or drop caps, and drops never remove it. +export type StreamQueueEntry = { + sessionId: string + data: string + /** Original PTY characters represented by data. Salvaged query copies are + * delivered bytes but represent zero new positions in the source stream. */ + sequenceChars?: number + control?: DaemonEvent +} + +export type PendingStreamDataBatch = { + timer: ReturnType | null + queue: StreamQueueEntry[] + queuedChars: number + // Per-session held totals so the flush hold can spare small talkers + // (echo/replies) from waiting behind other sessions' floods. + queuedCharsBySession: Map + // Last droppable-sessions-with-queued-data count seen by the keep-tail + // logic: when it GROWS the shared budget tightens, and sessions that + // finished producing must be re-trimmed (they will never re-enqueue). + lastDroppableSessionCount?: number +} + +// The keep-tail must comfortably cover a full TUI repaint (~cols×rows×SGR ≈ +// 100KB) so the delivered tail always re-renders a coherent screen. +// Hysteresis (cap = 2× keep) bounds drop churn. +// Kill switch: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0 disables thinning. +const BACKGROUND_SESSION_KEEP_TAIL_CHARS = 512 * 1024 +const BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS = 64 * 1024 +// Why a GLOBAL budget too: the per-session cap bounds each flood, but N +// backgrounded sessions can still queue N×cap in aggregate — and a reveal +// (worktree switch) then waits behind the whole aggregate at the gated drain +// rate (~8MB/s event-loop-turn-bound; measured 9MB queued → 2.5s hidden +// restore vs the 1.5s budget). Shrinking each session's keep-tail as more +// backgrounded sessions queue keeps the total ~2MB, so any reveal drains in +// ~250ms while every pane still keeps at least a full screen of tail. +const BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS = 2 * 1024 * 1024 + +export function backgroundSessionKeepTailChars(droppableSessionsWithQueuedData: number): number { + return Math.min( + BACKGROUND_SESSION_KEEP_TAIL_CHARS, + Math.max( + BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS, + Math.floor(BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS / Math.max(1, droppableSessionsWithQueuedData)) + ) + ) +} + +export function backgroundSessionDropCapChars(droppableSessionsWithQueuedData: number): number { + return backgroundSessionKeepTailChars(droppableSessionsWithQueuedData) * 2 +} +// Mirrors main's DROPPED_QUERY_SALVAGE_MAX_CHARS: salvage past this means a +// pathological query stream; keep the O(1) memory guarantee. A prior drop's +// salvage entry is itself the oldest data and re-salvages through the next +// drop, so query order is preserved across repeated drops. +const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + +/** Trim the session's OLDEST queued data down to the keep-tail and leave (or + * grow) a dataGap control entry where the dropped bytes were. Control + * entries are never dropped. Boundary note: the kept tail can start + * mid-escape-sequence — deliberate; the receiver treats a gap as a + * tail-preview reset and transient-fact scanning is daemon-authoritative + * for droppable sessions, so nothing downstream parses across the cut. */ +export function dropOldestQueuedForSession( + batch: PendingStreamDataBatch, + sessionId: string, + keepTailChars: number, + salvageDroppedData: (dropped: string) => string +): void { + let toDrop = (batch.queuedCharsBySession.get(sessionId) ?? 0) - keepTailChars + if (toDrop <= 0) { + return + } + const totalDropped = toDrop + let droppedSequenceChars = 0 + let salvaged = '' + const salvageIntoCap = (dropped: string): void => { + if (salvaged.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return + } + salvaged = (salvaged + salvageDroppedData(dropped)).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS) + } + let existingGap: DataGapEvent | null = null + let insertGapAt = -1 + for (let i = 0; i < batch.queue.length && toDrop > 0; i++) { + const entry = batch.queue[i] + if (entry.sessionId !== sessionId) { + continue + } + if (entry.control) { + if (entry.control.event === 'dataGap') { + existingGap = entry.control + } + continue + } + if (entry.data.length <= toDrop) { + toDrop -= entry.data.length + droppedSequenceChars += entry.sequenceChars ?? entry.data.length + salvageIntoCap(entry.data) + if (insertGapAt === -1) { + insertGapAt = i + } + batch.queue.splice(i, 1) + i-- + } else { + const cut = clampToSafeSplitIndex(entry.data, 0, toDrop) + if (cut > 0) { + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const cutSequenceChars = entrySequenceChars === 0 ? 0 : cut + droppedSequenceChars += cutSequenceChars + salvageIntoCap(entry.data.slice(0, cut)) + entry.data = entry.data.slice(cut) + const remainingSequenceChars = entrySequenceChars - cutSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + if (insertGapAt === -1) { + insertGapAt = i + } + } + toDrop = 0 + } + } + const dropped = totalDropped - toDrop + if (dropped <= 0) { + return + } + batch.queuedChars -= dropped + batch.queuedCharsBySession.set( + sessionId, + Math.max(0, (batch.queuedCharsBySession.get(sessionId) ?? 0) - dropped) + ) + if (existingGap) { + const priorSequenceChars = existingGap.payload.sequenceChars ?? existingGap.payload.droppedChars + existingGap.payload.droppedChars += dropped + existingGap.payload.sequenceChars = priorSequenceChars + droppedSequenceChars + } else { + recordDaemonStreamBacklogEvent('backgroundKeepTailDrop', { + sessionIdSuffix: sessionId.slice(-10), + droppedChars: dropped + }) + batch.queue.splice(Math.max(0, insertGapAt), 0, { + sessionId, + data: '', + control: { + type: 'event', + event: 'dataGap', + sessionId, + payload: { droppedChars: dropped, sequenceChars: droppedSequenceChars } + } + }) + insertGapAt = Math.max(0, insertGapAt) + 1 + } + if (salvaged.length > 0) { + // Salvaged query bytes ride as a tiny data entry at the gap position — + // the writing program is blocked on their replies. + const at = existingGap + ? batch.queue.findIndex((e) => e.control === existingGap) + 1 + : insertGapAt + batch.queue.splice(at, 0, { sessionId, data: salvaged, sequenceChars: 0 }) + batch.queuedChars += salvaged.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + salvaged.length + ) + } +} diff --git a/src/main/daemon/degraded-daemon-fallback-shutdown.ts b/src/main/daemon/degraded-daemon-fallback-shutdown.ts new file mode 100644 index 00000000000..0d4932d6c20 --- /dev/null +++ b/src/main/daemon/degraded-daemon-fallback-shutdown.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../providers/types' + +export async function shutdownDegradedFallbackSessions( + sessionProviders: Map, + fallback: T +): Promise { + const ids = [...sessionProviders] + .filter(([, provider]) => provider === fallback) + .map(([id]) => id) + const results = await Promise.allSettled( + ids.map(async (id) => { + await fallback.shutdown(id, { immediate: true }) + sessionProviders.delete(id) + }) + ) + // Why: fallback cleanup must not abort the user's daemon-restart recovery path. + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length > 0) { + console.warn( + `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, + ...failed.map((result) => (result as PromiseRejectedResult).reason) + ) + } + return results.length - failed.length +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 7bd656fc6fb..fb0b5087f15 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,13 +4,14 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' type ProviderMock = IPtyProvider & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void emitReplay: (id: string, data: string) => void emitExit: (id: string, code: number) => void } function createProvider(label: string, sessions: string[] = []): ProviderMock { - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { @@ -41,15 +42,17 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } } } - }), + ), onReplay: vi.fn((callback: (payload: { id: string; data: string }) => void) => { replayListeners.push(callback) return () => { @@ -68,9 +71,9 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { } } }), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) } }, emitReplay: (id: string, data: string) => { @@ -151,6 +154,30 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.write).not.toHaveBeenCalled() }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const snapshot = { + data: 'alt frame', + scrollbackAnsi: 'normal history', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + current.getBufferSnapshot = vi.fn(async () => snapshot) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + + await provider.discoverDaemonSessions() + + await expect( + provider.getBufferSnapshot('daemon-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + expect(current.getBufferSnapshot).toHaveBeenCalledWith('daemon-session', { + scrollbackRows: 50_000 + }) + }) + it('forwards replay output from fallback and daemon providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') @@ -174,6 +201,22 @@ describe('DegradedDaemonPtyProvider', () => { }) }) + it('preserves explicit sequence accounting on daemon data events', () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const dataSpy = vi.fn() + provider.onData(dataSpy) + + current.emitData('daemon-session', '\x1b[6n', 0) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'daemon-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + }) + it('detaches provider subscriptions without disposing the underlying providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index a7b8c68fa19..4e818c9b6c0 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,6 +1,9 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -23,7 +26,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: ManagedPtyProvider private sessionProviders = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { @@ -93,6 +100,18 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.providerFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.providerFor(id).pauseProducer?.(id) + } + + resumeProducer(id: string): void { + this.providerFor(id).resumeProducer?.(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.providerFor(id).setPtyBackgrounded?.(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.providerFor(id).shutdown(id, opts) if (!opts.keepHistory) { @@ -116,6 +135,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return (await this.providerFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + // Why: a preserved legacy daemon can still thin its monitoring stream; + // recovery must reach the adapter that owns that session's full model. + return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null + } + async clearBuffer(id: string): Promise { await this.providerFor(id).clearBuffer(id) } @@ -155,7 +183,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.fallback.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -165,6 +195,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allProviders().flatMap( + (provider) => provider.onBackgroundStreamEvent?.(callback) ?? [] + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(callback: (payload: { id: string; data: string }) => void): () => void { const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback)) let active = true @@ -237,28 +278,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async shutdownFallbackSessions(): Promise { - const ids = [...this.sessionProviders] - .filter(([, provider]) => provider === this.fallback) - .map(([id]) => id) - const results = await Promise.allSettled( - ids.map(async (id) => { - await this.fallback.shutdown(id, { immediate: true }) - this.sessionProviders.delete(id) - }) - ) - // Why: this runs first in the daemon-restart sequence. A throw here would - // abort the whole restart and leave "Restart daemon" — the user's recovery - // path for a wedged terminal — unusable, recreating the original lockup. So - // it is best-effort: log failures, keep restarting, and only count the - // sessions that actually shut down. - const failed = results.filter((result) => result.status === 'rejected') - if (failed.length > 0) { - console.warn( - `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, - ...failed.map((result) => (result as PromiseRejectedResult).reason) - ) - } - return results.length - failed.length + return shutdownDegradedFallbackSessions(this.sessionProviders, this.fallback) } getCurrentDaemonSessionIds(): string[] { diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts new file mode 100644 index 00000000000..8ce1191722d --- /dev/null +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../shared/agent-tui-ansi-fuzz-stream' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRowStyles, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +// Differential garble gate for the hidden-terminal model/view contract +// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the +// source of truth and reveal repaints the renderer xterm from +// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). +// This fuzz feeds seeded agent-TUI byte streams to the production emulator +// and to an always-visible renderer-parity terminal, then asserts the +// serialize→replay round trip reproduces the exact screen the renderer would +// have shown. Any diff = a garble bug on reveal. +// +// Runtime knobs: +// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with +// the reveal-reconciliation suite) +// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log) + +const DEFAULT_ITERATIONS = 300 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) +// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the +// scrollback budget the reveal restore actually requests from main. +const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 }, + { cols: 120, rows: 40 } +] + +type FidelityCase = { + seed: number + dims: AgentTuiStreamDims + ops: string[] + chunked: boolean +} + +type FidelityDiff = { + stage: string + expected: unknown + actual: unknown + /** True when the always-visible buffer matches the known upstream + * @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see + * bufferHasSerializeHostileWrappedRow and the skipped repro test below. */ + knownSerializeWrapBug?: boolean +} + +function buildCase(seed: number): FidelityCase { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + const opCount = 12 + Math.floor(rng() * 28) + const ops = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: true, + includeOscHyperlinks: false, + opCount + }) + return { seed, dims, ops, chunked: true } +} + +function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null { + return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual } +} + +async function runFidelityCase(testCase: FidelityCase): Promise { + const stream = testCase.ops.join('') + const chunks = testCase.chunked + ? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, { + minLen: 3, + maxLen: 120 + }) + : [stream] + const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows }) + const control = createRendererParityTerminal(testCase.dims) + const restored = createRendererParityTerminal(testCase.dims) + try { + for (const chunk of chunks) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, chunks) + + // Stage 1 — model fidelity: the emulator's screen must already match the + // renderer twin before any serialization enters the picture. + const modelDiff = firstDiff( + 'model-visible (HeadlessEmulator vs renderer twin)', + visibleRows(control.terminal), + emulator.getVisibleLines() + ) + if (modelDiff) { + return modelDiff + } + + // Stage 2 — reveal round trip: serialize exactly like + // serializeHiddenOutputRecoveryBuffer, replay exactly like + // applyMainBufferSnapshot, then compare against the always-visible twin. + const alt = emulator.isAlternateScreen + const snapshot = emulator.getSnapshot({ + scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + }) + await writeChunksToTerminal(restored.terminal, [ + alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + + const diffs = [ + firstDiff( + 'restore-visible-text', + visibleRows(control.terminal), + visibleRows(restored.terminal) + ), + firstDiff( + 'restore-visible-styles', + visibleRowStyles(control.terminal), + visibleRowStyles(restored.terminal) + ), + firstDiff( + 'restore-cursor', + cursorPosition(control.terminal), + cursorPosition(restored.terminal) + ), + firstDiff( + 'restore-mode-bracketed-paste', + control.terminal.modes.bracketedPasteMode, + restored.terminal.modes.bracketedPasteMode + ), + // Why alt is excluded from the two comparisons below: + // - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts) + // deliberately forces scrollbackRows=0 while an alt-screen TUI is + // active, so normal-buffer history is not part of the alt contract. + // - application cursor: HeadlessEmulator.getModes reports + // applicationCursor false on the alternate buffer, so rehydrate omits + // ?1h there by design. + alt + ? null + : firstDiff( + 'restore-scrollback-text', + normalBufferRowsTrimmed(control.terminal), + normalBufferRowsTrimmed(restored.terminal) + ), + alt + ? null + : firstDiff( + 'restore-mode-application-cursor', + control.terminal.modes.applicationCursorKeysMode, + restored.terminal.modes.applicationCursorKeysMode + ) + ] + const diff = diffs.find((candidate) => candidate !== null) ?? null + if (!diff) { + return null + } + if (bufferHasSerializeHostileWrappedRow(control.terminal)) { + return { ...diff, knownSerializeWrapBug: true } + } + return diff + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } +} + +/** Greedy op-drop minimizer: re-runs the full differential pipeline on + * smaller op lists so a failure report carries the smallest byte stream that + * still diverges (plus its seed for exact replay via FUZZ_SEED). */ +async function minimizeFailure(testCase: FidelityCase): Promise { + let current = { ...testCase, chunked: false } + if ((await runFidelityCase(current)) === null) { + current = { ...testCase, chunked: true } + } + let budget = 400 + let shrunk = true + while (shrunk && budget > 0) { + shrunk = false + for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) { + const candidate = { ...current, ops: current.ops.toSpliced(i, 1) } + budget -= 1 + if ((await runFidelityCase(candidate)) !== null) { + current = candidate + shrunk = true + } + } + } + return current +} + +function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string { + return [ + `HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`, + `seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`, + `dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`, + `minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`, + `expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`, + `actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}` + ].join('\n') +} + +describe('headless emulator snapshot fidelity fuzz', () => { + // Known-legitimate divergence, pinned so it cannot silently regress into a + // real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon + // never re-emits OSC 8, and production compensates by shipping the ranges + // out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the + // renderer link provider to re-register. Byte-replay therefore keeps the + // TEXT but not the link underline — the metadata must carry the range. + it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => { + const emulator = new HeadlessEmulator({ cols: 60, rows: 10 }) + const restored = createRendererParityTerminal({ cols: 60, rows: 10 }) + try { + await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail') + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)[0]).toBe('review link tail') + expect(snapshot.oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 11, + uri: 'https://example.com/pr/7' + }) + } finally { + emulator.dispose() + restored.terminal.dispose() + } + }) + + it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { + // The known-and-pinned serialize wrap bug (A) is tolerated + counted so + // deep mode (FUZZ_ITERATIONS) surfaces only GENUINELY NEW divergences. + // Bugs B (bold-reset, fixed by the addon patch) and C (margin cursor, + // fixed by the absolute-cursor epilogue) are no longer tolerated — a + // regression fails the corpus loudly and the unskipped repros below. + let knownSerializeWrapBugHits = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const testCase = buildCase(seed) + const diff = await runFidelityCase(testCase) + if (diff?.knownSerializeWrapBug) { + knownSerializeWrapBugHits += 1 + continue + } + if (diff) { + const minimized = await minimizeFailure(testCase) + const minimizedDiff = await runFidelityCase(minimized) + expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) + } + } + // Guard the tolerance from swallowing the suite: the predicate tripping + // on most seeds means the gate has gone degenerate. + expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) + }, 600_000) + + // ── HEADLINE FINDING (do not delete while unfixed upstream) ────────────── + // @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells + // that touch a soft-wrap boundary. Two variants, both found by this fuzz + // and minimized below. Every Orca snapshot consumer is affected: hidden + // reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe + // replay paint lost/shifted characters or stray '-' fillers whenever a TUI + // erased inside a soft-wrapped line (shell line editing, status lines wider + // than the pane, Claude Code in-place prompt redraws). + // + // V1 — cell loss (found by seed 31, minimized to 2 ops): + // Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214) + // nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth + // ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + // binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row + // passes as a "natural" wrap. The serializer then emits the previous row as + // full-width text (leaving xterm in wrap-pending) and skips the null cell + // with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of + // crossing the wrap boundary, so the next character overwrites the previous + // row's last cell and the whole tail shifts left by one. + // cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K' + // live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12'] + // serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D' + // replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted + // + // V2 — stray filler '-' (found by seed 157, minimized below): when the + // SOURCE row of a wrapped pair is entirely null (a TUI erased the whole + // first half of a wrapped line), the addon's forced-wrap "magic" writes + // nullCellCount+1 dashes and then cleans up with + // ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ... + // With length === nullCellCount that cursor-forward becomes `ESC[0C`, and + // CSI param 0 means 1, so the ECH erase lands one cell right and the first + // '-' stays visible on the restored row. + // Unskip once the upstream fix (or a local serialize post-processor) lands. + it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // V2 repro of the headline finding above (stray '-' filler on a fully + // erased wrapped source row). Unskip alongside the V1 repro. + it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + // Wrap a 28-char line, then erase the entire first (source) row of the + // wrapped pair: cursor up twice onto it, EL 2. + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + // Fails today: restored row 0 shows '-' where the live row is blank. + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug B regression guard: SGR bold on a dim→bold-only cell transition ── + // Upstream @xterm/addon-serialize emitted `\x1b[1;22m` for this transition; + // SGR 22 (normalIntensity) clears BOTH bold and dim, so the restored cell + // lost its bold. FIXED by the intensity-group reorder in + // config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch (22 before + // 1/2). Found by fuzz seeds 435, 770, 1321; mechanism in + // notes/garble-fuzz-divergences.md (Bug B). + it('preserves bold when serializing a dim cell followed by a bold-only cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 4 }) + const control = createRendererParityTerminal({ cols: 20, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 4 }) + try { + // 'A' dim, 'B' bold-only. Live: A=dim, B=bold. The patched serializer + // emits 22;1 for the B transition (clear before re-set). + const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug C regression guard: cursor exact when the last row fills the margin ── + // Upstream @xterm/addon-serialize computes its relative cursor-restore from + // a wrap-pending position and lands one column short. FIXED Orca-side: the + // emulator snapshot appends an absolute CUP from the source's authoritative + // cursor (serializeWithAbsoluteCursor). Found by fuzz seeds 454, 1696; + // mechanism in notes/garble-fuzz-divergences.md (Bug C). + it('restores the cursor exactly when the last content row fills the right margin', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 4 }) + const control = createRendererParityTerminal({ cols: 10, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 10, rows: 4 }) + try { + // Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a + // known lower-row column. Live cursor is (x=4, y=2). + const bytes = ['0123456789\x1b[3;5H'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) +}) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 0a5832247c6..b9630b8ff7e 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -98,6 +98,74 @@ describe('HeadlessEmulator', () => { uri: 'https://example.com/issue/1234' }) }) + + it('serializes split synchronized rich TUI frames for model-backed replay', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 12 }) + const richFrame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h', + '\x1b[?2026l' + ].join('') + + // Why: hidden rich TUI bytes may arrive split across DEC 2026 frame + // boundaries; model/view work needs the headless model to preserve the + // final visible state before renderer writes can be removed. + await emulator.write(richFrame.slice(0, 17)) + await emulator.write(richFrame.slice(17, 91)) + await emulator.write(richFrame.slice(91)) + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.alternateScreen).toBe(true) + expect(snapshot.snapshotAnsi).toContain('Codex rich restore') + expect(snapshot.snapshotAnsi).toContain('🟢') + expect(snapshot.snapshotAnsi).toContain('███░') + expect(snapshot.snapshotAnsi).toContain('╭') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi) + const replayed = replay.getSnapshot() + expect(replayed.modes.alternateScreen).toBe(true) + expect(replayed.snapshotAnsi).toContain('Codex rich restore') + expect(replayed.snapshotAnsi).toContain('🟢') + expect(replayed.snapshotAnsi).toContain('███░') + } finally { + replay.dispose() + } + }) + + it('preserves the normal buffer behind an alternate-screen snapshot', async () => { + emulator = new HeadlessEmulator({ cols: 40, rows: 6 }) + await emulator.write('shell history one\r\nshell history two') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HTUI frame') + + const snapshot = emulator.getSnapshot() + expect(snapshot.scrollbackAnsi).toContain('shell history one') + expect(snapshot.snapshotAnsi).toContain('TUI frame') + expect(snapshot.snapshotAnsi).not.toContain('shell history one') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write( + snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi + ) + expect(replay.getVisibleLines().join('\n')).toContain('TUI frame') + + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell history one') + expect(replay.getVisibleLines().join('\n')).toContain('shell history two') + } finally { + replay.dispose() + } + }) }) describe('OSC-7 CWD tracking', () => { @@ -305,6 +373,49 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) }) + it('tracks kitty keyboard flags for emulator re-seed parity', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=5;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => { + // Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags, + // a private xterm surface. If an xterm upgrade breaks that path this + // must fail loudly instead of the responder silently answering ?0u. + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[>1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1) + }) + + it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Kitty flags are per screen buffer: entering the alt screen swaps to + // its own (empty) flag set, exactly what a CSI ? u reply would report. + await emulator.write('\x1b[=5;1u\x1b[?1049h') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=3;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3) + + await emulator.write('\x1b[?1049l') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('never pushes kitty flags into rehydrateSequences', async () => { + // Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay + // authoritative for renderer replays (terminal-query-authority.md). + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[=5;1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(5) + expect(snapshot.rehydrateSequences).not.toContain('u') + }) + it('tracks split SGR mouse reporting sequences', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) @@ -499,14 +610,16 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') }) - it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => { + it('records kitty flags without pushing them into renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) // OMP/pi negotiate progressive enhancement with a level-1 push. await emulator.write('\x1b[>1u') const snapshot = emulator.getSnapshot() expect(snapshot.modes.kittyKeyboardFlags).toBe(1) - expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u') + // Why: renderer replay deliberately resets stale CSI-u state; the daemon + // warm-reattach path re-seeds the model from modes.kittyKeyboardFlags. + expect(snapshot.rehydrateSequences).not.toContain('\x1b[=1;1u') }) it('omits kitty rehydration after the TUI pops its flags', async () => { @@ -519,7 +632,7 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('u') }) - it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => { + it('keeps kitty flags out of alternate-screen renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?1049h\x1b[>1u') @@ -527,7 +640,7 @@ describe('HeadlessEmulator', () => { const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h') const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u') expect(altScreenIndex).toBeGreaterThanOrEqual(0) - expect(kittyIndex).toBeGreaterThan(altScreenIndex) + expect(kittyIndex).toBe(-1) }) it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 7ce69db0cc8..e7da2a4589e 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,13 +3,21 @@ 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 { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from '../../shared/terminal-serialize-absolute-cursor' import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' -import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' -import { extractLastOscTitle } from '../../shared/agent-detection' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' 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 { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' +import { + installTerminalViewAttributeResponder, + type TerminalViewAttributeResponder +} from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -17,48 +25,87 @@ export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number + /** Phase-5 model query responder sink (terminal-query-authority.md). + * When set, xterm-core auto-replies generated while parsing a write + * flagged `forwardQueryReplies` are forwarded here; all other emissions + * (seeds, hydration, snapshot replay, unsolicited core pushes) are + * discarded. The daemon Session must NEVER pass this — its emulator + * stays write-only forever (contract invariant: the daemon never + * answers). */ + onQueryReply?: (reply: string) => void pathFlavor?: 'posix' | 'win32' remotePosixFileUriAuthority?: boolean } +export type HeadlessEmulatorWriteOptions = { + /** Reply ownership captured at ingestion for this exact chunk. Default + * false is the main-side replay guard (twin of the renderer's + * replay-guard.ts): seed/hydration/snapshot writes never forward. */ + forwardQueryReplies?: boolean +} + type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void + // Why: kitty keyboard flags are not on the public IModes; read the core + // service state the CSI =/>/< u handlers mutate. + coreService?: { + kittyKeyboard?: { flags?: number } + } } } const DEFAULT_SCROLLBACK = 5000 -const OSC_SCAN_TAIL_LIMIT = 4096 +// Keep in sync with the renderer twin in terminal-capability-replies.ts +// (main must not import renderer modules). +const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon - private cwd: string | null = null - private lastTitle: string | null = null - private oscScanTail = '' - private privateModes = new TerminalPrivateModeTracker() - private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() - private restoredOscLinks: TerminalOscLinkRange[] = [] - // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's - // parser, not the screen buffer, so serialize() drops them and the next - // chunk's continuation renders literally after a remote snapshot restore - // (#7329). Track the unparsed trailing partial at ingest (committed after - // xterm parses the same bytes, like the private-mode mirror) and ship it in - // the snapshot so the restorer can complete the sequence. - private partialEscapeTail = '' - private disposed = false + // Why: our restructure owns cwd/title via TerminalOscCwdTitleScanner and the + // DECSET mouse modes via TerminalMouseModeMirror (functionally identical to + // main's inline cwd/lastTitle/oscScanTail + TerminalPrivateModeTracker, which + // only tracks the same mouse modes). restoredOscLinks/disposed/partialEscapeTail + // are declared below. + private oscText: TerminalOscCwdTitleScanner + private mouseModes = new TerminalMouseModeMirror() private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean + private restoredOscLinks: TerminalOscLinkRange[] = [] + private disposed = false + private onQueryReply: ((reply: string) => void) | null + private conptyDa1OverrideInstalled = false + private viewAttributeResponder: TerminalViewAttributeResponder | null = null + // Why: replies must be scoped to the exact write that carried the query. + // The window opens around the parse of a forward-flagged chunk and closes + // with it, so seeds/snapshots and unsolicited core emissions (e.g. native + // 997 pushes from option mutations) can never leak to the PTY. + private queryReplyForwardingDepth = 0 + // Why: a chunk ending mid-escape leaves the sequence in xterm's parser, not + // the buffer, so serialize() drops it and the next chunk's continuation + // renders literal after a restore (Bug E, notes/garble-fuzz-divergences.md). + // Committed alongside mouseModes: only after xterm parsed the same bytes. + private partialEscapeTail = '' constructor(opts: HeadlessEmulatorOptions) { this.pathFlavor = opts.pathFlavor this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true + this.oscText = new TerminalOscCwdTitleScanner({ + pathFlavor: this.pathFlavor, + remotePosixAuthority: this.remotePosixFileUriAuthority + }) this.terminal = new Terminal({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, - logLevel: 'off' + logLevel: 'off', + // Why: parity with the renderer's buildDefaultTerminalOptions — parse + // CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app + // actually pushed. Write-only daemon use is unaffected: keyboard state + // never alters serialization (terminal-query-authority.md §kitty). + vtExtensions: { kittyKeyboard: true } }) this.serializer = new SerializeAddon() @@ -72,34 +119,136 @@ export class HeadlessEmulator { 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 - // data in-process synchronously before `handleSubprocessData` forwards - // it to the renderer over IPC, so any reply it emits would land on the - // shell's stdin ahead of the renderer's xterm reply and win the race. - // The renderer is the authoritative responder (it has the real theme, - // cursor position, and paste mode); a daemon-side reply would be a - // double-reply with wrong values. OSC 11 was the visible casualty: - // Claude Code's /theme auto always saw the emulator's default-black - // background regardless of Orca's configured terminal theme. + // Why onData is gated behind onQueryReply: by default this emulator is + // pure state tracking and MUST NOT respond to terminal query sequences + // (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data + // in-process synchronously before `handleSubprocessData` forwards it to + // the renderer over IPC, so any reply it emitted would land on the + // shell's stdin ahead of the renderer's xterm reply and win the race — + // a double-reply with default-xterm values (OSC 11 default-black was + // the visible casualty). Only main's runtime per-PTY emulators pass a + // sink, and even then replies flow only for chunks the hidden-delivery + // gate DROPPED, where the renderer never sees the bytes and main is the + // single answerer. See docs/reference/terminal-query-authority.md. + this.onQueryReply = opts.onQueryReply ?? null + if (this.onQueryReply) { + this.terminal.onData((reply) => this.emitQueryReply(reply)) + } } - write(data: string): Promise { + /** Main-side twin of the renderer's terminal-capability-replies.ts: + * ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override + * variant (`CSI ?61;4c`) must win. Returning true consumes the query so + * xterm core's default `?1;2c` cannot double-reply (custom CSI handlers + * run before core's; false falls through). The reply still routes through + * the forwarding window, so replayed/seeded bytes never answer. */ + installConptyPrimaryDeviceAttributesOverride(): void { + // Why idempotent: the spawn mark can land after daemon stream data + // already created the emulator, so the override is installed both at + // creation and retrofitted at mark time — never stacked. + if (this.conptyDa1OverrideInstalled) { + return + } + this.conptyDa1OverrideInstalled = true + this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => { + const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0) + if (!isPrimaryQuery) { + return false + } + this.emitQueryReply(CONPTY_DA1_RESPONSE) + return true + }) + } + + /** Phase-5 slice-2 view-attribute bridge: the headless core has no theme + * service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the + * renderer's pushed attributes via these parser handlers — never from + * emulator defaults. Runtime-only, like onQueryReply: the daemon Session + * must NEVER call this (its emulator stays write-only forever). */ + installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void { + if (this.viewAttributeResponder) { + return + } + this.viewAttributeResponder = installTerminalViewAttributeResponder({ + parser: this.terminal.parser, + getBaseAttributes, + // emitQueryReply keeps replies inside the per-chunk forwarding window, + // so seeded/replayed view-attribute queries answer no one. + emitReply: (reply) => this.emitQueryReply(reply) + }) + } + + /** Applies a renderer view-attribute push: cursor options make xterm core + * answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC + * color overrides are dropped because a theme apply overwrites mutated + * colors on visible panes too (ThemeService._setTheme parity). Option + * writes happen outside any forwarding window, so any core emission they + * trigger is discarded (main-side replay guard). */ + applyPushedViewAttributes(attributes: TerminalViewAttributes): void { + if (this.disposed) { + return + } + this.terminal.options.cursorStyle = attributes.cursorStyle + this.terminal.options.cursorBlink = attributes.cursorBlink + this.viewAttributeResponder?.clearColorOverrides() + } + + /** Re-seed parity for snapshot `modes.kittyKeyboardFlags` + * (terminal-query-authority.md §kitty): replays the persisted flags + * through the same `CSI = flags ; 1 u` parse a live push uses, so hidden + * `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write — + * outside any forwarding window, it can never answer anything — and never + * into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty + * reset stays authoritative). */ + applyKittyKeyboardFlags(flags: number): Promise { + if (!Number.isInteger(flags) || flags <= 0) { + return Promise.resolve() + } + return this.write(`\x1b[=${flags};1u`) + } + + private emitQueryReply(reply: string): void { + if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { + this.onQueryReply(reply) + } + } + + /** Severs the reply sink at PTY teardown. Queued writeChain links may + * still parse after dispose is requested, and daemon respawns reuse + * session ids — a late reply must never reach a successor PTY. */ + disableQueryReplyForwarding(): void { + this.onQueryReply = null + } + + write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise { if (this.disposed) { return Promise.resolve() } - if (this.tryWriteSync(data)) { + const forwardQueryReplies = opts.forwardQueryReplies === true + if (this.tryWriteSync(data, { forwardQueryReplies })) { return Promise.resolve() } - this.scanInputForOscState(data) + this.oscText.scan(data) + // Why the sentinel: xterm parses queued writes asynchronously, so opening + // the window at enqueue time would leak it over earlier queued unflagged + // chunks (seed/hydration bytes parsing while depth > 0). Write callbacks + // fire in FIFO parse order, so a zero-byte write whose callback opens the + // window brackets the parse of exactly this chunk; the data callback + // closes it. + if (forwardQueryReplies) { + this.terminal.write('', () => { + this.queryReplyForwardingDepth += 1 + }) + } return new Promise((resolve) => { this.terminal.write(data, () => { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. - this.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -117,31 +266,30 @@ export class HeadlessEmulator { return this.tryWriteSync(data) } - private tryWriteSync(data: string): boolean { + private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean { const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync !== 'function') { return false } - this.scanInputForOscState(data) + this.oscText.scan(data) + const forwardQueryReplies = opts.forwardQueryReplies === true + if (forwardQueryReplies) { + this.queryReplyForwardingDepth += 1 + } // 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.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + try { + writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) + } finally { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } + } + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } - private scanInputForOscState(data: string): void { - const oscInput = this.oscScanTail + data - this.oscScanTail = this.extractOscScanTail(oscInput) - this.scanOsc7(oscInput) - const lastTitle = extractLastOscTitle(oscInput) - if (lastTitle !== null) { - this.lastTitle = lastTitle - } - } - resize(cols: number, rows: number): void { if (this.disposed) { return @@ -160,38 +308,65 @@ export class HeadlessEmulator { getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot { const modes = this.getModes() - const snapshotAnsi = this.normalizeSnapshotAnsiForModes( - this.serializer.serialize({ scrollback: opts.scrollbackRows }), - modes + // Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor + // restore lands one column short after a margin-filling final row leaves + // replay wrap-pending; the trailing CUP survives the alt-marker slice. + // The saved-cursor register rides along so a post-restore DECRC lands + // where the hidden TUI saved, not at home. + const serializedAnsi = serializeWithAbsoluteCursor( + this.serializer, + this.terminal, + { scrollback: opts.scrollbackRows }, + readSavedCursorRegister(this.terminal) ) - return { + const { snapshotAnsi, scrollbackAnsi } = splitTerminalSnapshotAnsi(serializedAnsi, modes) + const snapshot: TerminalSnapshot = { snapshotAnsi, - scrollbackAnsi: '', + scrollbackAnsi, oscLinks: collectHeadlessOscLinkRanges( this.terminal, opts.scrollbackRows, this.restoredOscLinks ), - rehydrateSequences: this.buildRehydrateSequences(modes), - cwd: this.cwd, + rehydrateSequences: buildRehydrateSequences(modes), + cwd: this.oscText.cwd, modes, cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined, + lastTitle: this.oscText.lastTitle ?? undefined, // Why: written LAST by the restorer (after any reset) so the next live // chunk completes this dangling sequence instead of rendering it literally - // (#7329). Its bytes are already counted by the snapshot seq. + // (Bug E / #7329). Its bytes are already counted by the snapshot seq. ...(this.partialEscapeTail.length > 0 ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } + if (this.partialEscapeTail.length > 0) { + // Why a separate field, not part of snapshotAnsi: consumers write their + // own reset sequences after the snapshot body, and any ESC written after + // a dangling partial would abort it. The restorer must write this LAST, + // immediately before post-snapshot live chunks. Its bytes are already + // counted by the snapshot seq (they were ingested), so tail-slicing + // arithmetic is unchanged. + snapshot.pendingEscapeTailAnsi = this.partialEscapeTail + } + return snapshot } get isAlternateScreen(): boolean { return this.terminal.buffer.active.type === 'alternate' } + /** The dangling incomplete escape at the current stream position (empty + * when none). Scan-authority handoffs seed the other side's fact scanners + * with it so a sequence split across the handoff neither mints a phantom + * bell (unseen OSC terminator) nor loses its fact. Contains no complete + * sequence by construction, so seeding can never double-fire. */ + get partialEscapeTailAnsi(): string { + return this.partialEscapeTail + } + /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with * pending input it re-renders at a cached buffer row that ConPTY's fixed * viewport doesn't track, painting the input well below the prompt. The @@ -219,15 +394,15 @@ export class HeadlessEmulator { } getCwd(): string | null { - return this.cwd + return this.oscText.cwd } setCwd(cwd: string | null): void { - this.cwd = cwd + this.oscText.cwd = cwd } setLastTitle(title: string): void { - this.lastTitle = title + this.oscText.lastTitle = title } setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void { @@ -244,102 +419,25 @@ export class HeadlessEmulator { this.terminal.dispose() } - private scanOsc7(data: string): void { - scanOsc7Uris(data, (uri) => { - this.parseOsc7Uri(uri) - }) - } - - private extractOscScanTail(input: string): string { - return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) - } - - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { - if (!modes.alternateScreen) { - return snapshotAnsi - } - const alternateScreenMarker = '\x1b[?1049h' - const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) - if (start === -1) { - return snapshotAnsi - } - // Why: rehydrateSequences already enters the alternate screen and restores - // mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's - // "slice from last alt-screen marker" replay from discarding those modes. - return snapshotAnsi.slice(start + alternateScreenMarker.length) - } - - private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri, { - pathFlavor: this.pathFlavor, - remotePosixAuthority: this.remotePosixFileUriAuthority - }) - if (parsed) { - this.cwd = parsed - } - } - private getModes(): TerminalModes { const buffer = this.terminal.buffer.active - const mouseTrackingMode = this.privateModes.mouseTrackingMode + const mouseTrackingMode = this.mouseModes.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: this.privateModes.sgrMouseMode, - sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, + sgrMouseMode: this.mouseModes.sgrMouseMode, + sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate', - kittyKeyboardFlags: this.kittyKeyboardModes.flags + kittyKeyboardFlags: this.getKittyKeyboardFlags() } } - private buildRehydrateSequences(modes: TerminalModes): string { - const seqs: string[] = [] - if (modes.alternateScreen) { - seqs.push('\x1b[?1049h') - } - if (modes.bracketedPaste) { - seqs.push('\x1b[?2004h') - } - if (modes.applicationCursor) { - seqs.push('\x1b[?1h') - } - // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored - // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. - switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { - case 'x10': - seqs.push('\x1b[?9h') - break - case 'vt200': - seqs.push('\x1b[?1000h') - break - case 'drag': - seqs.push('\x1b[?1002h') - break - case 'any': - seqs.push('\x1b[?1003h') - break - case 'none': - break - } - // Why: xterm tracks the mouse protocol and SGR encoding as independent - // modes, so snapshots must preserve the encoding even when reporting is off. - if (modes.sgrMousePixelsMode) { - seqs.push('\x1b[?1016h') - } else if (modes.sgrMouseMode) { - seqs.push('\x1b[?1006h') - } - // Why: kitty keyboard flags are per-screen state SerializeAddon cannot - // capture; without re-arming them, the still-running TUI keeps expecting - // protocol-encoded keys the restored client no longer sends. `=` (set) - // instead of `>` (push) so repeated replays cannot grow the flag stack. - // Emitted after the alt-screen switch above so the flags land on the - // screen the TUI negotiated them on. - if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) { - seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`) - } - return seqs.join('') + private getKittyKeyboardFlags(): number { + const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard + ?.flags + return typeof flags === 'number' ? flags : 0 } } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 9453488c2d9..47b96b14547 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -132,6 +132,24 @@ export class HistoryManager { }) } + // Why: wake after sleep re-spawns a session whose history was closed by the + // sleep-time kill. Re-register the writer without deleting checkpoint.json + // (still the only recovery data until the next tick) and clear endedAt so + // the next sleep can cold-restore this session again. + reopenSession(sessionId: string): void { + this.disabledSessions.delete(sessionId) + this.registerWriter(sessionId) + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + try { + this.updateMeta(writer.dir, { endedAt: null, exitCode: null }) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + suspendSession(sessionId: string): void { // Why: if a fresh daemon cannot accept recovered scrollback, leaving its // writer active would let the next checkpoint overwrite the only good copy. diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index f6661b50044..8d31e3c638d 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -152,7 +152,13 @@ export class HistoryReader { }) try { if (checkpoint) { - if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) { + if ( + !emulator.writeSync( + (checkpoint.scrollbackAnsi ?? '') + + checkpoint.rehydrateSequences + + checkpoint.snapshotAnsi + ) + ) { return null } emulator.setRestoredOscLinks(checkpoint.oscLinks) @@ -198,12 +204,8 @@ export class HistoryReader { cwd: string | null, meta: SessionMeta ): ColdRestoreInfo { - // Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi - // (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the - // normal buffer content and is safe to use as scrollback. For alt-screen - // snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY - // stream); return empty instead — the adapter skips cold restore when - // scrollbackAnsi is falsy. + // Why: legacy normal snapshots stored their buffer only in snapshotAnsi; + // current alt snapshots carry their normal buffer in scrollbackAnsi. const scrollbackAnsi = snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi) return { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c732a16e1cf..119b3fb9430 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1068,6 +1068,30 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true } }, + // Why pause/resume work on Windows too: node-pty's base Terminal + // implements both as socket pause/resume (lib/terminal.js), and + // WindowsTerminal wires _socket to the ConPTY conout pipe — pausing stops + // conout reads so ConPTY's bounded buffer backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, clear: () => { if (dead) { return diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index a8bdf252442..aec624cbf01 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -37,7 +37,7 @@ function createMockSubprocess(): SubprocessHandle & { // for a reattach. Alt-screen sessions include the full ANSI snapshot because function buildReattachPayload(snapshot: ReturnType) { const isAltScreen = snapshot.modes.alternateScreen - const fullPayload = snapshot.rehydrateSequences + snapshot.snapshotAnsi + const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi return { rehydrateSequences: snapshot.rehydrateSequences, snapshotAnsi: snapshot.snapshotAnsi, @@ -274,6 +274,24 @@ describe('reattach snapshot flow', () => { expect(payload.fullPayload).toContain('Codex TUI content') }) + it('returns to preserved shell history after the reattached TUI exits', async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 10 }) + await emulator.write('shell output before Codex\r\n$ codex') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content') + const payload = buildReattachPayload(emulator.getSnapshot()) + emulator.dispose() + + const replay = new HeadlessEmulator({ cols: 80, rows: 10 }) + try { + await replay.write(payload.fullPayload) + expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content') + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex') + } finally { + replay.dispose() + } + }) + it('SIGWINCH repaint after rehydrate produces clean single render', async () => { const h = createHost() await h.createOrAttach({ diff --git a/src/main/daemon/session-ingest-throughput.bench.test.ts b/src/main/daemon/session-ingest-throughput.bench.test.ts new file mode 100644 index 00000000000..e226ddf869d --- /dev/null +++ b/src/main/daemon/session-ingest-throughput.bench.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { performance } from 'node:perf_hooks' +import { Session, type SubprocessHandle } from './session' + +// Benchmark harness for the terminal performance initiative: measures the +// daemon-side ingest rate (Session.handleSubprocessData -> HeadlessEmulator +// write + pending-output recording + client fanout) for the same workload +// shapes as tools/benchmarks/terminal-pipeline-bench.mjs. Bare headless +// xterm parses these at ~80-100 MB/s; the end-to-end Orca pipeline measured +// 2-15 MB/s (baseline-jul02) — this isolates the daemon layer's share. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/main/daemon/session-ingest-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +const COLS = 114 +const ROWS = 85 +const TARGET_BYTES = 10 * 1024 * 1024 +const CHUNK = 64 * 1024 + +function asciiLog(targetBytes: number): string { + const parts: string[] = [] + let bytes = 0 + let line = 0 + while (bytes < targetBytes) { + line++ + const s = `\x1b[32m[build ${String(line).padStart(6, '0')}]\x1b[0m compile transform resolve bundle emit chunk module (${line % 5000}ms)\r\n` + parts.push(s) + bytes += s.length + } + return parts.join('') +} + +function agentTui(targetBytes: number): string { + const statusRows = 10 + const parts: string[] = [] + let bytes = 0 + let frame = 0 + let painted = false + const push = (s: string): void => { + parts.push(s) + bytes += Buffer.byteLength(s, 'utf8') + } + while (bytes < targetBytes) { + frame++ + push('\x1b[?2026h') + if (painted) { + push(`\x1b[${statusRows}A\x1b[0J`) + } + push(`\x1b[2m●\x1b[0m transcript line for frame ${frame} with some words\r\n`) + for (let r = 0; r < statusRows; r++) { + push( + `\x1b[38;5;${33 + (r % 6)}m⠼ task ${frame % 100}·${r}\x1b[0m ${'▇'.repeat((frame + r) % 40)}\r\n` + ) + } + painted = true + push('\x1b[?2026l') + } + return parts.join('') +} + +function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } { + let onData: ((data: string) => void) | null = null + return { + pid: 4242, + getForegroundProcess: () => 'bench', + write: () => {}, + resize: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + onData: (cb) => { + onData = cb + }, + onExit: () => {}, + dispose: () => {}, + emit: (data: string) => onData?.(data) + } +} + +function ingest(fixture: string, drainPendingEveryChunks: number | null): number { + const subprocess = makeSubprocess() + const session = new Session({ + sessionId: 'bench', + cols: COLS, + rows: ROWS, + subprocess, + shellReadySupported: false + }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + // Warmup primes JIT paths. + subprocess.emit(fixture.slice(0, 256 * 1024)) + session.takePendingOutput(false) + const start = performance.now() + let chunks = 0 + for (let i = 0; i < fixture.length; i += CHUNK) { + subprocess.emit(fixture.slice(i, i + CHUNK)) + chunks++ + // Why: without periodic takes the 2MB pending cap overflows and recording + // short-circuits, understating the real steady-state cost. The 5s adapter + // tick drains in production; drain per ~1.5MB approximates a hot session. + if (drainPendingEveryChunks && chunks % drainPendingEveryChunks === 0) { + session.takePendingOutput(false) + } + } + const ms = performance.now() - start + session.dispose() + return ms +} + +describe.skipIf(!benchEnabled)('daemon session ingest throughput', () => { + it('measures MB/s per workload shape', () => { + const rows: string[] = [] + for (const [name, fixture] of [ + ['ascii-log', asciiLog(TARGET_BYTES)], + ['agent-tui', agentTui(TARGET_BYTES)] + ] as const) { + const bytes = Buffer.byteLength(fixture, 'utf8') + const ms = ingest(fixture, 24) + const rate = bytes / 1024 / 1024 / (ms / 1000) + rows.push( + `${name}: ${rate.toFixed(1)} MB/s (${ms.toFixed(0)}ms for ${(bytes / 1024 / 1024).toFixed(1)}MB)` + ) + } + // eslint-disable-next-line no-console -- bench harness output + console.log(`\n[session-ingest] ${COLS}x${ROWS}\n ${rows.join('\n ')}`) + expect(rows.length).toBe(2) + }) +}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 478b4ea99f0..ade8b18fe15 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Session } from './session' +import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' // Stub the subprocess — Session talks to it via an interface, not child_process directly. @@ -11,6 +11,8 @@ function createMockSubprocess() { let killed = false let clearCalls = 0 let pid = 12345 + let pauseCalls = 0 + let resumeCalls = 0 return { written, @@ -21,6 +23,12 @@ function createMockSubprocess() { get pid() { return pid }, + get pauseCalls() { + return pauseCalls + }, + get resumeCalls() { + return resumeCalls + }, foregroundProcess: null as string | null, getForegroundProcess(): string | null { return this.foregroundProcess @@ -29,6 +37,12 @@ function createMockSubprocess() { written.push(data) }, resize(_cols: number, _rows: number) {}, + pause() { + pauseCalls++ + }, + resume() { + resumeCalls++ + }, get clearCalls() { return clearCalls }, @@ -174,14 +188,22 @@ describe('Session', () => { describe('emulator does not reply to terminal queries', () => { // Why: daemon emulator parses in-process synchronously — before - // handleSubprocessData forwards bytes to the renderer over IPC — so any - // auto-reply it emits races ahead of the renderer's xterm and clobbers - // it with default-xterm values (no theme, stale cursor). The renderer is - // the authoritative responder; a daemon-side reply to any query is a bug. + // handleSubprocessData forwards bytes onward — so any auto-reply it + // emits races ahead of the live answerer and clobbers it with + // default-xterm values (no theme, stale cursor). Query authority is + // structural (terminal-query-authority.md): a delivered chunk is + // answered by the consuming view's xterm, a hidden-dropped chunk by + // MAIN's runtime model responder. The daemon emulator is neither — it + // stays write-only forever, and these pins are permanent. it.each([ + ['OSC 10 foreground-color', '\x1b]10;?\x07'], ['OSC 11 background-color', '\x1b]11;?\x07'], + ['OSC 12 cursor-color', '\x1b]12;?\x1b\\'], ['DA1 device-attributes', '\x1b[c'], - ['DSR cursor-position', '\x1b[6n'] + ['DA2 secondary device-attributes', '\x1b[>c'], + ['DSR terminal status', '\x1b[5n'], + ['DSR cursor-position', '\x1b[6n'], + ['DECRPM bracketed-paste mode', '\x1b[?2004$p'] ])('does not reply to %s query', async (_label, query) => { createSession({ shellReadySupported: false }) subprocess.simulateData(query) @@ -260,6 +282,15 @@ describe('Session', () => { expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready') }) + it('publishes an absolute output sequence with live snapshots', () => { + createSession() + subprocess.simulateData('first') + subprocess.simulateData('🟢second') + + expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length) + expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length) + }) + it('releases held marker-prefix bytes before flushing queued input on timeout', () => { createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) const received: string[] = [] @@ -600,4 +631,103 @@ describe('Session', () => { expect(session.state).toBe('exited') }) }) + + describe('producer flow control', () => { + it('pauses the subprocess and auto-resumes via the lost-resume failsafe', () => { + createSession() + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(1) + expect(subprocess.resumeCalls).toBe(0) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer resumes once and cancels the failsafe timer', () => { + createSession() + session.pauseProducer() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(1) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer without a matching pause is a no-op', () => { + createSession() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(0) + }) + + it('re-pausing re-arms the failsafe window', () => { + createSession() + session.pauseProducer() + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1_000) + session.pauseProducer() + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('kill() resumes a paused producer before signalling the child', () => { + createSession() + session.pauseProducer() + session.kill() + expect(subprocess.resumeCalls).toBe(1) + expect(subprocess.killed).toBe(true) + }) + + it('dispose() resumes a paused producer and clears the failsafe', () => { + createSession() + session.pauseProducer() + session.dispose() + expect(subprocess.resumeCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('subprocess exit clears the failsafe without resuming a reaped child', () => { + createSession() + session.pauseProducer() + subprocess.simulateExit(0) + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('ignores pauseProducer on an exited session', () => { + createSession() + subprocess.simulateExit(0) + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('detaching the last client resumes a paused producer', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('keeps the pause while another client is still attached', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('detachAllClients resumes a paused producer', () => { + createSession() + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachAllClients() + expect(subprocess.resumeCalls).toBe(1) + }) + }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 0d377c7f3f0..80987b5a1bb 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,11 @@ const KILL_TIMEOUT_MS = 5_000 // Worst-case wire size for a full take is ~6x this (each control char // JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB). const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 +// Why: producer pause is requested over a fire-and-forget notification, so the +// matching resume can be lost (main crash, dropped socket). A lost resume must +// never wedge a shell: auto-resume after this window; a still-flooded main +// re-asserts the pause on its next watermark check. +export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000 export type SubprocessHandle = { pid: number @@ -42,6 +47,11 @@ export type SubprocessHandle = { startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void + /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer + * fills and a flooding child blocks on write. Optional: handles that + * cannot pause simply omit it and flow control degrades to a no-op. */ + pause?(): void + resume?(): void /** Resync the native PTY's own screen state after a frontend clear. * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes * the next prompt repaint land below a blank gap. */ @@ -102,6 +112,9 @@ export class Session { private pendingOutputBytes = 0 private pendingOutputOverflowed = false private pendingOutputSeq = 0 + private outputSequence = 0 + private producerPaused = false + private producerPauseFailsafeTimer: ReturnType | null = null private readonly _historySeeded: boolean | undefined constructor(opts: SessionOptions) { @@ -198,12 +211,52 @@ export class Session { this.subprocess.resize(cols, rows) } + /** Producer-side flow control: stop reading the PTY fd so the flooding + * child blocks on write (kernel backpressure). Arms the lost-resume + * failsafe; re-pausing re-arms it (main re-asserts during long floods). */ + pauseProducer(): void { + if (this._state === 'exited' || this._disposed) { + return + } + this.producerPaused = true + this.subprocess.pause?.() + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + } + this.producerPauseFailsafeTimer = setTimeout(() => { + this.producerPauseFailsafeTimer = null + this.producerPaused = false + this.subprocess.resume?.() + }, PRODUCER_PAUSE_FAILSAFE_MS) + } + + resumeProducer(): void { + this.releaseProducerPause({ resume: true }) + } + + private releaseProducerPause(opts: { resume: boolean }): void { + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + this.producerPauseFailsafeTimer = null + } + if (!this.producerPaused) { + return + } + this.producerPaused = false + if (opts.resume) { + this.subprocess.resume?.() + } + } + kill(): void { if (this._state === 'exited' || this._isTerminating) { return } this._isTerminating = true + // Why: a paused child can be blocked inside write(); resume before + // signalling so it can run signal handlers and actually exit. + this.releaseProducerPause({ resume: true }) this.subprocess.kill() this.killTimer = setTimeout(() => { @@ -231,17 +284,30 @@ export class Session { if (idx !== -1) { this.attachedClients.splice(idx, 1) } + // Why: with no attached client, nobody will ever send resumePty — a + // paused shell would sit wedged until the failsafe. Resume eagerly. + if (this.attachedClients.length === 0) { + this.releaseProducerPause({ resume: true }) + } } detachAllClients(): void { this.attachedClients.length = 0 + this.releaseProducerPause({ resume: true }) } - getSnapshot(): TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this._disposed) { return null } - return this.emulator.getSnapshot() + return { ...this.emulator.getSnapshot(opts), outputSequence: this.outputSequence } + } + + getPartialEscapeTailAnsi(): string { + if (this._disposed) { + return '' + } + return this.emulator.partialEscapeTailAnsi } // Why: the size the PTY actually applied (emulator dims, which Session.resize @@ -282,7 +348,7 @@ export class Session { : records, seq: this.pendingOutputSeq, overflowed, - snapshot: includeSnapshot ? this.emulator.getSnapshot() : null + snapshot: includeSnapshot ? this.getSnapshot() : null } } @@ -424,6 +490,9 @@ export class Session { return } this._disposed = true + // Why: never leave a paused fd behind on any teardown path — the handle's + // own dead-guard makes this a no-op when the child is already reaped. + this.releaseProducerPause({ resume: true }) if (this.killTimer) { clearTimeout(this.killTimer) this.killTimer = null @@ -490,6 +559,10 @@ export class Session { return } + // Why: daemon stream thinning can omit bytes before main sees them. The + // absolute count lets an authoritative snapshot cover those gaps while + // renderer reconciliation deduplicates any queued post-snapshot tail. + this.outputSequence += data.length // Feed data to headless emulator for state tracking this.emulator.write(data) this.recordPendingOutput({ kind: 'output', data }) @@ -507,6 +580,9 @@ export class Session { this._exitCode = code this._state = 'exited' + // Why resume:false — the child is reaped, so there is nothing to unblock; + // only the failsafe timer must not outlive the session. + this.releaseProducerPause({ resume: false }) this.releaseHeldShellReadyBytes() if (this.killTimer) { diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 8cd9a42a307..544ff2a4c75 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -183,7 +183,7 @@ describe('incremental terminal history restore', () => { expect(restore!.scrollbackAnsi).not.toContain('cleared away') }) - it('skips restorable content for sessions crashed inside the alt screen', async () => { + it('preserves normal history without treating active alt content as scrollback', async () => { await manager.appendIncrements(SESSION_ID, 1, [ { kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' } ]) @@ -191,9 +191,8 @@ describe('incremental terminal history restore', () => { const restore = reader.detectColdRestore(SESSION_ID) expect(restore).not.toBeNull() expect(restore!.modes.alternateScreen).toBe(true) - // Why: the adapter skips cold restore when scrollbackAnsi is empty — alt - // buffer contents must not replay into a fresh shell. - expect(restore!.scrollbackAnsi).toBe('') + expect(restore!.scrollbackAnsi).toContain('normal output') + expect(restore!.scrollbackAnsi).not.toContain('alt screen content') }) it('resets the log on checkpoint so old records are not replayed twice', async () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 8b8968931f5..644356a6c4f 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -197,6 +197,21 @@ export class TerminalHost { this.getAliveSession(sessionId).resize(cols, rows) } + // Why null-not-throw (unlike write/resize): pause/resume are best-effort + // flow-control hints; a session that exited while the notify was in flight + // must not surface an error or a synthetic exit. + pauseProducer(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return + } + session.pauseProducer() + } + + resumeProducer(sessionId: string): void { + this.sessions.get(sessionId)?.resumeProducer() + } + kill(sessionId: string, opts: { immediate?: boolean } = {}): void { const session = this.getAliveSession(sessionId) this.recordTombstone(sessionId) @@ -266,12 +281,22 @@ export class TerminalHost { // Why: unlike getAliveSession (which throws), this returns null for dead/missing // sessions. Checkpoint is best-effort — a session that exited between the timer // firing and the RPC arriving should not throw. - getSnapshot(sessionId: string): TerminalSnapshot | null { + getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { const session = this.sessions.get(sessionId) if (!session || !session.isAlive) { return null } - return session.getSnapshot() + return session.getSnapshot(opts) + } + + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — the + // emulator's dangling incomplete escape at the current stream position. + getPartialEscapeTailAnsi(sessionId: string): string { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return '' + } + return session.getPartialEscapeTailAnsi() } // Why: read-only readback of the size the PTY actually applied (null-not-throw diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts new file mode 100644 index 00000000000..77e3603d4f5 --- /dev/null +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -0,0 +1,47 @@ +import type { TerminalModes } from './types' + +// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and +// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C +// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for +// emulator re-seed parity only; a re-seeded emulator answers ?0u and +// protocol-conformant programs re-push. +export function buildRehydrateSequences(modes: TerminalModes): string { + const seqs: string[] = [] + if (modes.alternateScreen) { + // Why: normal-buffer serialization can leave its pen active, while the + // separately serialized alt body assumes it starts from default SGR. + seqs.push('\x1b[0m\x1b[?1049h') + } + if (modes.bracketedPaste) { + seqs.push('\x1b[?2004h') + } + if (modes.applicationCursor) { + seqs.push('\x1b[?1h') + } + // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored + // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + // Why: xterm tracks the mouse protocol and SGR encoding as independent + // modes, so snapshots must preserve the encoding even when reporting is off. + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + return seqs.join('') +} diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts new file mode 100644 index 00000000000..8f8b284f7f4 --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -0,0 +1,119 @@ +import type { TerminalModes } from './types' + +type MouseTrackingMode = NonNullable + +// 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 + +/** + * Mirrors DECSET mouse-protocol/encoding state from the raw byte stream. + * xterm's public modes API does not expose which mouse protocol is active, + * so snapshots track it independently of the headless terminal; callers + * must feed `scan()` the same bytes the terminal parsed, in order. + */ +export class TerminalMouseModeMirror { + private scanTail = '' + private trackingModeState: MouseTrackingMode = 'none' + private sgrMouseModeState = false + private sgrMousePixelsModeState = false + + get mouseTrackingMode(): MouseTrackingMode { + return this.trackingModeState + } + + get sgrMouseMode(): boolean { + return this.sgrMouseModeState + } + + get sgrMousePixelsMode(): boolean { + return this.sgrMousePixelsModeState + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path for + // every session; a flood chunk with no private-mode/reset introducer + // must not pay the regex pass (measured share of a 2.2x ingest + // regression — findings log 2026-07-03). Split sequences stay correct: + // an introducer split across chunks either left a non-empty scanTail + // (previous partial) or ends this chunk, which extractScanTail retains. + if ( + this.scanTail.length === 0 && + !data.includes('\x1b[?') && + !data.includes('\x1bc') && + !data.includes('\x9b') + ) { + this.scanTail = this.extractScanTail(data) + return + } + const input = this.scanTail.length === 0 ? data : 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.trackingModeState = 'none' + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = 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.trackingModeState = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.trackingModeState = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.trackingModeState = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.trackingModeState = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseModeState = enabled + this.sgrMousePixelsModeState = false + } + if (param === 1016) { + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = 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/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..f3736d7c009 --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -0,0 +1,53 @@ +import { extractLastOscTitle } from '../../shared/agent-detection' +import { parseFileUriPath } from './osc7-file-uri' +import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' + +const OSC_SCAN_TAIL_LIMIT = 4096 + +/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd + * updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so + * sequences split across PTY chunks still parse. Uses the bounded regex-free + * scanners so giant pasted chunks stay cheap. */ +export type TerminalOscCwdTitleScannerOptions = { + pathFlavor?: 'posix' | 'win32' + remotePosixAuthority?: boolean +} + +export class TerminalOscCwdTitleScanner { + private scanTail = '' + private readonly parseOptions: TerminalOscCwdTitleScannerOptions + cwd: string | null = null + lastTitle: string | null = null + + constructor(options: TerminalOscCwdTitleScannerOptions = {}) { + this.parseOptions = options + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path; flood + // chunks with no OSC introducer must not pay the title/URI walks + // (measured share of a 2.2x ingest regression — findings log 2026-07-03). + // Correctness across splits: an OSC intro spanning chunks either left a + // non-empty scanTail or this chunk ends with a bare ESC, which + // extractOscScanTail retains for the next call. + if (this.scanTail.length === 0 && !data.includes('\x1b]')) { + this.scanTail = data.endsWith('\x1b') ? extractOscScanTail(data, OSC_SCAN_TAIL_LIMIT) : '' + return + } + const input = this.scanTail.length === 0 ? data : this.scanTail + data + this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) + scanOsc7Uris(input, (uri) => { + const parsed = parseFileUriPath(uri, { + pathFlavor: this.parseOptions.pathFlavor, + remotePosixAuthority: this.parseOptions.remotePosixAuthority + }) + if (parsed) { + this.cwd = parsed + } + }) + const lastTitle = extractLastOscTitle(input) + if (lastTitle !== null) { + this.lastTitle = lastTitle + } + } +} diff --git a/src/main/daemon/terminal-snapshot-ansi-buffers.ts b/src/main/daemon/terminal-snapshot-ansi-buffers.ts new file mode 100644 index 00000000000..e0b16d56d8f --- /dev/null +++ b/src/main/daemon/terminal-snapshot-ansi-buffers.ts @@ -0,0 +1,21 @@ +import type { TerminalModes } from './types' + +export function splitTerminalSnapshotAnsi( + snapshotAnsi: string, + modes: TerminalModes +): { snapshotAnsi: string; scrollbackAnsi: string } { + if (!modes.alternateScreen) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + const alternateScreenMarker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) + if (start === -1) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + // Why: rehydrateSequences owns the alt-screen transition. Keeping the + // normal buffer separate lets an already-alt renderer rebuild it safely. + return { + scrollbackAnsi: snapshotAnsi.slice(0, start), + snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length) + } +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts new file mode 100644 index 00000000000..b54227652dd --- /dev/null +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -0,0 +1,254 @@ +// Round-trip guards for two @xterm/addon-serialize defects that garbled +// hidden-terminal snapshot restores (serialize a buffer, replay into a fresh +// identical terminal, compare): +// +// BUG B (fixed by config/patches/@xterm__addon-serialize@*.patch): the SGR +// attribute diff emitted bold/dim set params before the shared intensity +// reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a +// still-set bold/dim. +// +// BUG C (hardened Orca-side via serializeWithAbsoluteCursor): a final content +// row filled exactly to the right margin leaves replay wrap-pending, and the +// addon's RELATIVE cursor restore then lands one column short. +import './xterm-env-polyfill' +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { HeadlessEmulator } from './headless-emulator' +import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor' + +type TerminalHarness = { terminal: Terminal; addon: SerializeAddon } + +function createTerminal(cols = 10, rows = 5, scrollback = 100): TerminalHarness { + const terminal = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + const addon = new SerializeAddon() + terminal.loadAddon(addon) + return { terminal, addon } +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, () => resolve())) +} + +async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise { + const { terminal } = createTerminal(cols, rows, scrollback) + await write(terminal, data) + return terminal +} + +function cellAt( + terminal: Terminal, + viewportRow: number, + col: number +): NonNullable< + ReturnType>['getCell']> +> { + const buffer = terminal.buffer.active + const line = buffer.getLine(buffer.baseY + viewportRow) + if (!line) { + throw new Error(`no line at viewport row ${viewportRow}`) + } + const cell = line.getCell(col) + if (!cell) { + throw new Error(`no cell at ${viewportRow},${col}`) + } + return cell +} + +function visibleText(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let row = 0; row < terminal.rows; row += 1) { + lines.push(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '') + } + return lines +} + +async function roundTripStyles(source: string): Promise { + const { terminal, addon } = createTerminal() + await write(terminal, source) + return replay(addon.serialize()) +} + +describe('SGR intensity round-trip (BUG B, addon patch)', () => { + it('restores bold set immediately after dim is cleared (minimized repro)', async () => { + const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(b.getChars()).toBe('B') + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps bold when dim is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps dim when bold is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold after normal text', async () => { + const restored = await roundTripStyles('A\x1b[1mB') + expect(!!cellAt(restored, 0, 0).isBold()).toBe(false) + expect(!!cellAt(restored, 0, 1).isBold()).toBe(true) + }) + + it('non-regression: dim after bold', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[22m\x1b[2mB') + const a = cellAt(restored, 0, 0) + const b = cellAt(restored, 0, 1) + expect(!!a.isBold()).toBe(true) + expect(!!a.isDim()).toBe(false) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold+dim accumulation survives', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(true) + }) + + it('non-regression: italic after underline is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[3mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isItalic()).toBe(true) + expect(!!b.isUnderline()).toBe(false) + }) + + it('non-regression: underline after italic is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[3mA\x1b[23m\x1b[4mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isUnderline()).toBe(true) + expect(!!b.isItalic()).toBe(false) + }) + + it('non-regression: underline dropped alongside bold set', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[1;4mB\x1b[24mC') + const c = cellAt(restored, 0, 2) + expect(!!c.isBold()).toBe(true) + expect(!!c.isUnderline()).toBe(false) + }) +}) + +describe('cursor restore after wrap-pending replay (BUG C, absolute-cursor hardening)', () => { + const REPRO = '0123456789\x1b[3;5H' + + it('documents the upstream defect: plain serialize lands one column short', async () => { + // Why this pin: the Orca hardening exists only because of this relative- + // restore defect. If an addon bump makes this fail, the hardening can go. + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + expect(terminal.buffer.active.cursorX).toBe(4) + const restored = await replay(addon.serialize()) + expect(restored.buffer.active.cursorX).toBe(3) + }) + + it('HeadlessEmulator snapshot restores the exact cursor (minimized repro)', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 5 }) + expect(emulator.writeSync(REPRO)).toBe(true) + const snapshot = emulator.getSnapshot() + const restored = await replay(snapshot.snapshotAnsi) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + expect(visibleText(restored)[0]).toBe('0123456789') + emulator.dispose() + }) + + it('serializeWithAbsoluteCursor restores the exact cursor', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('never changes already-correct restores at various cursor positions', async () => { + const positions = ['\x1b[1;1H', '\x1b[2;4H', '\x1b[5;10H', '\x1b[4;1H'] + for (const cup of positions) { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, `hello\r\nworld${cup}`) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(terminal.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(terminal.buffer.active.cursorY) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + } + }) + + it('leaves a wrap-pending source untouched so replay stays wrap-pending', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '0123456789') + // cursorX == cols marks pending wrap; a CUP would clamp and clear it. + expect(terminal.buffer.active.cursorX).toBe(10) + const plain = addon.serialize() + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe(plain) + const restored = await replay(plain) + await write(restored, 'Z') + expect(visibleText(restored)[1]).toBe('Z') + }) + + it('handles a wrap-pending row that is NOT the final content row', async () => { + const { terminal, addon } = createTerminal(10, 5) + // Row 0 fills to the margin and wraps into row 1, then the cursor moves. + await write(terminal, '0123456789ABC\x1b[2;2H') + expect(terminal.buffer.active.cursorY).toBe(1) + expect(terminal.buffer.active.cursorX).toBe(1) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(1) + expect(hardenedRestore.buffer.active.cursorY).toBe(1) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + }) + + it('restores alt-screen snapshots without disturbing correct positioning', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, 'shell$\x1b[?1049h\x1b[2J\x1b[HTUI ROW\x1b[2;3H') + expect(terminal.buffer.active.type).toBe('alternate') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(2) + expect(restored.buffer.active.cursorY).toBe(1) + expect(visibleText(restored)[0]).toBe('TUI ROW') + }) + + it('fixes the wrap-pending off-by-one inside the alt screen too', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '\x1b[?1049h0123456789\x1b[3;5H') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('restores scrolled-back buffers with the cursor at its base-relative spot', async () => { + const { terminal, addon } = createTerminal(10, 3, 50) + for (let i = 0; i < 8; i += 1) { + await write(terminal, `line${i}\r\n`) + } + await write(terminal, '\x1b[2;3H') + const source = { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal), 10, 3, 50) + expect(restored.buffer.active.cursorX).toBe(source.x) + expect(restored.buffer.active.cursorY).toBe(source.y) + expect(visibleText(restored)).toEqual(visibleText(terminal)) + expect(restored.buffer.active.length).toBe(terminal.buffer.active.length) + }) + + it('keeps empty buffers serializing to an empty string', async () => { + const { terminal, addon } = createTerminal(10, 5) + expect(addon.serialize()).toBe('') + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe('') + }) +}) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..68ff83c349f --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -0,0 +1,191 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * headless emulator. The headless xterm core has no theme service, so these + * handlers compute replies from the renderer's pushed attribute snapshot, + * with per-PTY OSC SET mutations layered on top — mirroring exactly what the + * renderer's ThemeService reports for a visible pane. Replies route through + * the caller's emit sink, which the slice-1 forwarding window already gates, + * so seeded/replayed bytes and delivered chunks never produce a reply. + */ +import type { Terminal } from '@xterm/headless' +import { + formatXColorRgbSpec, + parseXColorSpec, + TERMINAL_VIEW_ANSI_COLOR_COUNT, + type TerminalViewAttributes, + type TerminalViewRgb +} from '../../shared/terminal-view-attributes' + +type ViewAttributeParser = Pick + +export type TerminalViewAttributeResponderDeps = { + parser: ViewAttributeParser + /** Last renderer push, or null before the first push. Null means SILENCE + * for every view-attribute query — a fabricated default would resurrect + * the default-black OSC-11 bug (design invariant 3). */ + getBaseAttributes: () => TerminalViewAttributes | null + /** Must already be replay/forwarding-window gated by the caller. */ + emitReply: (reply: string) => void +} + +export type TerminalViewAttributeResponder = { + /** A changed renderer attribute push replaces the whole palette, exactly + * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors + * on a visible pane's theme apply. Identical re-pushes (fresh renderer + * process) are filtered in main's store and never reach this. */ + clearColorOverrides: () => void +} + +type SpecialColorSlot = 'foreground' | 'background' | 'cursor' + +// OSC 10/11/12 stack extra params onto consecutive slots (xterm's +// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] +const SPECIAL_COLOR_IDENTS: Record = { + foreground: '10', + background: '11', + cursor: '12' +} + +function isValidColorIndex(value: number): boolean { + return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT +} + +// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — +// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +function relativeLuminance([r, g, b]: TerminalViewRgb): number { + const linear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 +} + +export function installTerminalViewAttributeResponder( + deps: TerminalViewAttributeResponderDeps +): TerminalViewAttributeResponder { + // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); + // they die with the emulator at teardown, like every other model state. + // They deliberately survive a reveal→re-hide cycle even though the revealed + // xterm restores without palette mutations (SerializeAddon emits no OSC + // color SETs): the TUI never reset its SET, so holding it is + // protocol-correct — the visible-side loss is the pre-existing restore + // limitation, not this model's. + const ansiOverrides = new Map() + const specialOverrides = new Map() + + const reportColor = (ident: string, rgb: TerminalViewRgb): void => { + // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the + // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) + } + + const handleSpecialColor = (data: string, offset: number): boolean => { + const slots = data.split(';') + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= SPECIAL_COLOR_SLOTS.length) { + break + } + const slot = SPECIAL_COLOR_SLOTS[offset] + if (slots[i] === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot]) + } + } else { + const rgb = parseXColorSpec(slots[i]) + if (rgb) { + specialOverrides.set(slot, rgb) + } + } + } + // True consumes the sequence; the headless core's own OSC 10/11/12 + // handler only fires an onColor event nothing consumes. + return true + } + + deps.parser.registerOscHandler(4, (data) => { + const slots = data.split(';') + while (slots.length > 1) { + const idx = slots.shift() as string + const spec = slots.shift() as string + if (!/^\d+$/.test(idx)) { + continue + } + const index = Number.parseInt(idx, 10) + if (!isValidColorIndex(index)) { + continue + } + if (spec === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index]) + } + } else { + const rgb = parseXColorSpec(spec) + if (rgb) { + ansiOverrides.set(index, rgb) + } + } + } + return true + }) + deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) + deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) + deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) + + // OSC 104/110/111/112 restore the themed color — dropping the override + // falls back to the pushed base, the model twin of ThemeService.restoreColor. + deps.parser.registerOscHandler(104, (data) => { + if (!data) { + ansiOverrides.clear() + return true + } + for (const slot of data.split(';')) { + if (/^\d+$/.test(slot)) { + ansiOverrides.delete(Number.parseInt(slot, 10)) + } + } + return true + }) + deps.parser.registerOscHandler(110, () => { + specialOverrides.delete('foreground') + return true + }) + deps.parser.registerOscHandler(111, () => { + specialOverrides.delete('background') + return true + }) + deps.parser.registerOscHandler(112, () => { + specialOverrides.delete('cursor') + return true + }) + + deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + if (params[0] !== 996) { + // Fall through to the core for every other private DSR (?6n CPR etc.). + return false + } + const base = deps.getBaseAttributes() + if (base) { + // Why luminance and not base.colorSchemeMode: a visible xterm answers + // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) + // background vs foreground (CoreBrowserTerminal._reportColorScheme), + // so a dark terminal theme in a light app mode still answers dark. + // colorSchemeMode is the app mode and feeds the 2031/997 path only. + const background = specialOverrides.get('background') ?? base.background + const foreground = specialOverrides.get('foreground') ?? base.foreground + const dark = relativeLuminance(background) < relativeLuminance(foreground) + deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`) + } + return true + }) + + return { + clearColorOverrides: () => { + ansiOverrides.clear() + specialOverrides.clear() + } + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index faf0df30996..663166e6fca 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,11 +3,13 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -// Why: daemons survive app updates; bump for IPC shape or baked behavior that -// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs. -export const PROTOCOL_VERSION = 19 +// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or +// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. +// Why: bump when adding daemon wire behavior so same-version old daemons do +// not silently accept the handshake and then reject new RPCs. +export const PROTOCOL_VERSION = 20 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -18,23 +20,27 @@ export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' // ─── Terminal Snapshot ────────────────────────────────────────────── export type TerminalSnapshot = { snapshotAnsi: string - /** Scrollback portion only (rows above the visible viewport). Write this - * to preserve history without interfering with TUI repaints. */ + /** Trailing incomplete escape sequence the emulator ingested but xterm's + * parser is still holding (a PTY read ended mid-escape). Restorers must + * write this LAST — after their own post-replay resets, immediately before + * post-snapshot live chunks — so the continuation bytes complete it + * exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its + * bytes are already counted by the snapshot seq. */ + pendingEscapeTailAnsi?: string + /** Normal buffer captured separately while snapshotAnsi holds an active + * alternate buffer. Empty for normal-screen snapshots. */ scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string - /** The trailing partial escape sequence left unparsed in the emulator when a - * PTY read ended mid-escape. serialize() cannot carry it (it lives in the - * parser, not the buffer), so the restorer must write it LAST — after any - * post-snapshot reset — so the next live chunk's continuation completes the - * sequence instead of rendering literally (#7329). */ - pendingEscapeTailAnsi?: string cwd: string | null modes: TerminalModes cols: number rows: number scrollbackLines: number lastTitle?: string + /** Absolute UTF-16 character count ingested by this live daemon session. + * Optional because persisted snapshots and older v19 daemons lack it. */ + outputSequence?: number } export type TerminalModes = { @@ -45,9 +51,14 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean - /** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated; - * 0/absent when inactive. SerializeAddon cannot capture these, so the - * emulator mirrors them for snapshot rehydration. */ + /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed + * parity ONLY. Consumed by the daemon warm-reattach path: the spawn + * result threads them into seedHeadlessTerminal, which re-applies them to + * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags) + * so hidden `CSI ? u` answers the real flags instead of ?0u. + * rehydrateSequences must never push these into a renderer xterm — + * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative + * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number } @@ -134,6 +145,38 @@ export type ResizeRequest = { } } +// ─── Producer flow control (v19+) ─────────────────────────────────── +// Why fire-and-forget notifications (like write/resize): pause/resume ride the +// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC +// reply, is what guarantees a paused shell can never stay wedged. +export type PausePtyRequest = { + id: string + type: 'pausePty' + payload: { + sessionId: string + } +} + +export type ResumePtyRequest = { + id: string + type: 'resumePty' + payload: { + sessionId: string + } +} + +// Why the notification stays backward-tolerated: unknown notify types are +// swallowed by old daemons. The adapter's v20 capability gate separately +// prevents v19 thinning without a sequence-safe recovery snapshot. +export type SetSessionBackgroundRequest = { + id: string + type: 'setSessionBackground' + payload: { + sessionId: string + background: boolean + } +} + export type KillRequest = { id: string type: 'kill' @@ -217,6 +260,7 @@ export type GetSnapshotRequest = { type: 'getSnapshot' payload: { sessionId: string + scrollbackRows?: number } } @@ -277,6 +321,9 @@ export type DaemonRequest = | CancelCreateOrAttachRequest | WriteRequest | ResizeRequest + | PausePtyRequest + | ResumePtyRequest + | SetSessionBackgroundRequest | KillRequest | SignalRequest | ListSessionsRequest @@ -351,30 +398,9 @@ export type DaemonSessionInfo = SessionInfo & { protocolVersion: number } -// ─── Events (Daemon → Client, on stream socket) ──────────────────── - -export type DataEvent = { - type: 'event' - event: 'data' - sessionId: string - payload: { data: string } -} - -export type ExitEvent = { - type: 'event' - event: 'exit' - sessionId: string - payload: { code: number } -} - -export type TerminalErrorEvent = { - type: 'event' - event: 'terminalError' - sessionId: string - payload: { message: string } -} - -export type DaemonEvent = DataEvent | ExitEvent | TerminalErrorEvent +// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so +// existing importers keep one types entry point. +export * from './daemon-stream-events' // ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── // @@ -399,23 +425,10 @@ export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB export const NOTIFY_PREFIX = 'notify_' // ─── Error types ──────────────────────────────────────────────────── -export class TerminalAttachCanceledError extends Error { - constructor(sessionId: string) { - super(`Attach canceled for session ${sessionId}`) - this.name = 'TerminalAttachCanceledError' - } -} - -export class DaemonProtocolError extends Error { - constructor(message: string) { - super(message) - this.name = 'DaemonProtocolError' - } -} - -export class SessionNotFoundError extends Error { - constructor(sessionId: string) { - super(`Session not found: ${sessionId}`) - this.name = 'SessionNotFoundError' - } -} +// Re-exported so existing importers of `./types` keep working; the classes +// live in daemon-errors.ts (this file is capped for wire-shape declarations). +export { + TerminalAttachCanceledError, + DaemonProtocolError, + SessionNotFoundError +} from './daemon-errors' diff --git a/src/main/index.ts b/src/main/index.ts index 2ff6ca879fc..975e3a4596b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -179,6 +179,7 @@ import { type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, @@ -187,6 +188,7 @@ import { } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -1504,7 +1506,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool ) { return } - mainWindow.webContents.send('pty:data', { id: ptyId, data }) + // Why: feed the per-PTY tracker directly (never onPtyData — emulator state, + // tails, transcripts, and stats must not see fabricated bytes) so synthetic + // titles/BELs reach pty:sideEffect consumers when main holds side-effect + // authority. + runtime?.ingestSyntheticTitleFrame(ptyId, data) + // Why: only the kill-switch-off renderer still byte-parses synthetic frames; + // under main authority the copy would just mint phantom ACKs for unmetered + // bytes (see synthetic-title-frame-routing.ts). + if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { + mainWindow.webContents.send('pty:data', { id: ptyId, data }) + } } function isSyntheticTitleWindowVisible(): boolean { @@ -1805,6 +1817,20 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, + // Why: derived title/bell/agent facts ride one batched main→renderer + // channel (terminal-side-effect-authority.md). The renderer's authority + // kill switch decides whether to consume. Headless serve never creates a + // window, so the dep is omitted entirely — the runtime then skips fact + // batch construction and the per-chunk bell walk. + ...(isServeMode + ? {} + : { + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + } + }), // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts new file mode 100644 index 00000000000..c85c4e2c8e0 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + isHiddenPtyDeliveryGateEnabled, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' + +const PTY_ID = 'pty-1' + +describe('pty hidden delivery gate', () => { + beforeEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('only operates when both kill switches are on (default on)', () => { + expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false) + expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false) + }) + + it('drops only hidden PTYs without registered delivery interest', () => { + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + + markHiddenRendererPty(PTY_ID) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe( + false + ) + + setRendererPtyDeliveryInterest(PTY_ID, true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + setRendererPtyDeliveryInterest(PTY_ID, false) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + }) + + it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => { + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false) + + // Why: unmark consumes the latch (and re-emits via its own return value); + // the next hidden period's first drop reports again. + unmarkHiddenRendererPty(PTY_ID) + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + }) + + it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => { + // Why: a hidden remount or renderer reload re-marks without an unhide in + // between — clearing the latch there would make reveal skip the restore. + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('reports drops on unhide so reveal can heal a replaced renderer view', () => { + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false) + + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) + + it('clears renderer-scoped state on reload while preserving drop memory', () => { + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + setRendererPtyDeliveryInterest('pty-2', true) + markHiddenRendererPty('pty-2') + + resetRendererScopedHiddenPtyDeliveryState() + + // Hidden marks and interest holds died with the old renderer process. + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + // pty-2's leaked interest is gone: re-marking gates it again. + markHiddenRendererPty('pty-2') + expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true) + // Drop memory survives so the new renderer's first unhide still restores. + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('clears all per-PTY state on teardown and tracks debug counters', () => { + markHiddenRendererPty(PTY_ID) + setRendererPtyDeliveryInterest('pty-2', true) + recordHiddenRendererPtyDataDrop(PTY_ID, 7) + recordHiddenRendererPtyDataDrop(PTY_ID, 5) + + expect(getHiddenRendererPtyDeliveryDebug()).toEqual({ + hiddenDeliveryGatedPtyCount: 1, + deliveryInterestPtyCount: 1, + hiddenDeliveryDroppedChars: 12, + hiddenDeliveryDroppedChunks: 2 + }) + + clearHiddenRendererPtyDeliveryState(PTY_ID) + clearHiddenRendererPtyDeliveryState('pty-2') + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts new file mode 100644 index 00000000000..77230f7300c --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -0,0 +1,154 @@ +/** + * Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of + * the terminal model/view architecture). + * + * The renderer marks a PTY hidden when no visible view consumes its bytes; + * main then drops renderer-bound delivery AFTER model ingestion — the runtime + * already parsed the chunk, and reveal restores from the model snapshot via + * the existing seq-guarded machinery. Any renderer party that still needs raw + * bytes (dispatcher sidecars) registers delivery + * interest, which suppresses the gate for that PTY. + * See docs/reference/terminal-side-effect-authority.md (Open Items). + */ +import type { GlobalSettings } from '../../shared/types' + +export type HiddenPtyDeliveryGateSettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' +> + +const hiddenRendererPtys = new Set() +// Why: sidecar consumers (paste-draft pacing, background agent launches, +// automation observers, and the kill-switch-off parked 2031 responder) need +// live bytes even while no visible view exists. Any +// registered interest suppresses the gate for that PTY. +const deliveryInterestRendererPtys = new Set() +// Why: reveal must restore from the model only when bytes were actually +// dropped. Doubles as the one-shot marker latch: the first gated drop emits a +// restore marker, and the latch is consumed only by unmark (which re-emits) +// or full PTY teardown — never by re-marking hidden, so drop memory survives +// hidden remounts and renderer reloads. +const droppedSinceHiddenPtys = new Set() + +let droppedHiddenDeliveryChars = 0 +let droppedHiddenDeliveryChunks = 0 + +/** Gate kill switches, both read main-side: the gate only operates under main + * side-effect authority AND the gate-specific setting (both default on). */ +export function isHiddenPtyDeliveryGateEnabled( + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + settings?.terminalMainSideEffectAuthority !== false && + settings?.terminalHiddenDeliveryGate !== false + ) +} + +/** Renderer-reported "no visible view needs bytes" bit. Never clears drop + * memory: a hidden remount or renderer reload re-marks an already-dropped + * PTY, and erasing the latch there would make the eventual reveal skip the + * restore. Unmark is the only consumer of the latch. */ +export function markHiddenRendererPty(id: string): void { + hiddenRendererPtys.add(id) +} + +/** Clears the hidden bit. Returns whether bytes were dropped while hidden so + * the caller can emit a restore marker to the now-visible renderer. */ +export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } { + hiddenRendererPtys.delete(id) + const droppedWhileHidden = droppedSinceHiddenPtys.delete(id) + return { droppedWhileHidden } +} + +export function isHiddenRendererPty(id: string): boolean { + return hiddenRendererPtys.has(id) +} + +/** For freeze diagnostics only: hidden ptys must appear in the per-pty report + * table even when the gate dropped every byte before any send/accounting. */ +export function getHiddenRendererPtyIds(): string[] { + return [...hiddenRendererPtys] +} + +/** Renderer-side ref-counted interest, surfaced as boolean transitions. */ +export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void { + if (interested) { + deliveryInterestRendererPtys.add(id) + } else { + deliveryInterestRendererPtys.delete(id) + } +} + +export function shouldDropHiddenRendererPtyData( + id: string, + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + isHiddenPtyDeliveryGateEnabled(settings) && + hiddenRendererPtys.has(id) && + !deliveryInterestRendererPtys.has(id) + ) +} + +/** Record one gated drop. Returns whether the caller should emit the one-shot + * empty restore-marker chunk (first drop since this PTY went hidden). */ +export function recordHiddenRendererPtyDataDrop( + id: string, + chars: number +): { shouldEmitRestoreMarker: boolean } { + droppedHiddenDeliveryChars += chars + droppedHiddenDeliveryChunks += 1 + if (droppedSinceHiddenPtys.has(id)) { + return { shouldEmitRestoreMarker: false } + } + droppedSinceHiddenPtys.add(id) + return { shouldEmitRestoreMarker: true } +} + +/** Renderer process replaced (reload / crash): its ref-counted interest + * holds and hidden marks died with it, so keeping them would gate (or + * force-feed) PTYs no live renderer party asked about. Drop memory is + * preserved — surviving daemon/SSH PTYs may have dropped bytes the old + * renderer never restored; the new renderer's first hidden/visible sync + * re-marks or unmarks and the unmark path re-emits the restore marker. */ +export function resetRendererScopedHiddenPtyDeliveryState(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() +} + +/** Full per-PTY teardown — wired into clearProviderPtyState so every exit + * path (local, daemon, SSH, connection teardown) releases gate state. */ +export function clearHiddenRendererPtyDeliveryState(id: string): void { + hiddenRendererPtys.delete(id) + deliveryInterestRendererPtys.delete(id) + droppedSinceHiddenPtys.delete(id) +} + +export type HiddenRendererPtyDeliveryDebug = { + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number +} + +export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug { + return { + hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size, + deliveryInterestPtyCount: deliveryInterestRendererPtys.size, + hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars, + hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks + } +} + +export function resetHiddenRendererPtyDeliveryDebugCounters(): void { + droppedHiddenDeliveryChars = 0 + droppedHiddenDeliveryChunks = 0 +} + +/** Test seam: reset all module state between tests. */ +export function _resetHiddenRendererPtyDeliveryGateForTest(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() + droppedSinceHiddenPtys.clear() + resetHiddenRendererPtyDeliveryDebugCounters() +} diff --git a/src/main/ipc/pty-producer-flow-control.test.ts b/src/main/ipc/pty-producer-flow-control.test.ts new file mode 100644 index 00000000000..ae7fcb6b655 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PRODUCER_FLOW_HIGH_WATERMARK_CHARS, + PRODUCER_FLOW_LOW_WATERMARK_CHARS, + PRODUCER_PAUSE_REASSERT_INTERVAL_MS, + PtyProducerFlowController +} from './pty-producer-flow-control' + +const HIGH = PRODUCER_FLOW_HIGH_WATERMARK_CHARS +const LOW = PRODUCER_FLOW_LOW_WATERMARK_CHARS + +describe('PtyProducerFlowController', () => { + let pauseProducer: ReturnType void>> + let resumeProducer: ReturnType void>> + let controller: PtyProducerFlowController + + beforeEach(() => { + vi.useFakeTimers() + pauseProducer = vi.fn<(id: string) => void>() + resumeProducer = vi.fn<(id: string) => void>() + controller = new PtyProducerFlowController({ + pauseProducer, + resumeProducer + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not pause at or below the high watermark', () => { + controller.update('pty-1', 0) + controller.update('pty-1', LOW) + controller.update('pty-1', HIGH) + expect(pauseProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('pauses exactly once when pending crosses the high watermark, not per chunk', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', HIGH + 64 * 1024) + controller.update('pty-1', HIGH + 128 * 1024) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(pauseProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(true) + }) + + it('resumes exactly once when pending drains below the low watermark', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', LOW - 1) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(false) + // A second drain report on the now-unpaused pty must not resume again. + controller.update('pty-1', 0) + expect(resumeProducer).toHaveBeenCalledTimes(1) + }) + + it('holds hysteresis: no flapping while pending sits between the watermarks', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + // Draining but still above LOW: stay paused, no extra calls either way. + controller.update('pty-1', HIGH - 16 * 1024) + controller.update('pty-1', 128 * 1024) + controller.update('pty-1', LOW) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(true) + // An unpaused pty hovering in the same band must not pause. + controller.update('pty-2', LOW + 1) + controller.update('pty-2', HIGH) + expect(pauseProducer).toHaveBeenCalledTimes(1) + }) + + it('re-asserts the pause after the failsafe interval while still flooded', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // Within the failsafe window: no re-assert even far above HIGH. + vi.advanceTimersByTime(PRODUCER_PAUSE_REASSERT_INTERVAL_MS - 1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // After the window (daemon failsafe has auto-resumed by now): re-pause. + vi.advanceTimersByTime(1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + + // The re-assert re-stamps the clock — no immediate third pause. + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + }) + + it('release resumes only ptys that are actually paused', () => { + controller.update('paused-pty', HIGH + 1) + controller.release('paused-pty') + controller.release('never-paused-pty') + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('paused-pty') + expect(controller.isPaused('paused-pty')).toBe(false) + }) + + it('releaseAll resumes every paused pty', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-3', LOW) + controller.releaseAll() + expect(resumeProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(resumeProducer).toHaveBeenCalledWith('pty-2') + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(false) + }) + + it('keeps bookkeeping consistent when the transport throws', () => { + pauseProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + resumeProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + expect(() => controller.update('pty-1', HIGH + 1)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(true) + expect(() => controller.update('pty-1', 0)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('tracks watermark state per pty independently', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-1', 0) + expect(pauseProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(true) + }) +}) diff --git a/src/main/ipc/pty-producer-flow-control.ts b/src/main/ipc/pty-producer-flow-control.ts new file mode 100644 index 00000000000..48a9fc0b678 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.ts @@ -0,0 +1,105 @@ +// Producer-side PTY flow control (notes/terminal-performance-initiative.md §5). +// Main tracks per-PTY renderer-pending chars; past HIGH it asks the provider to +// pause the actual PTY read (node-pty pause() → kernel backpressure → the +// flooding shell blocks on write), and below LOW it resumes. The wide +// HIGH/LOW gap is deliberate hysteresis so a draining queue cannot flap +// pause/resume once per flush slice. + +export const PRODUCER_FLOW_HIGH_WATERMARK_CHARS = 256 * 1024 +export const PRODUCER_FLOW_LOW_WATERMARK_CHARS = 32 * 1024 +// Why: the daemon auto-resumes a pause after its 5s lost-resume failsafe. If +// pending is still above HIGH after that window, the pause must be re-asserted +// or a sustained flood would run unthrottled after the first failsafe fires. +export const PRODUCER_PAUSE_REASSERT_INTERVAL_MS = 5_000 + +export type ProducerFlowControlTransport = { + pauseProducer: (id: string) => void + resumeProducer: (id: string) => void +} + +export class PtyProducerFlowController { + private transport: ProducerFlowControlTransport + private highWatermarkChars: number + private lowWatermarkChars: number + private reassertIntervalMs: number + private pausedAtByPty = new Map() + + constructor( + transport: ProducerFlowControlTransport, + opts: { + highWatermarkChars?: number + lowWatermarkChars?: number + reassertIntervalMs?: number + } = {} + ) { + this.transport = transport + this.highWatermarkChars = opts.highWatermarkChars ?? PRODUCER_FLOW_HIGH_WATERMARK_CHARS + this.lowWatermarkChars = opts.lowWatermarkChars ?? PRODUCER_FLOW_LOW_WATERMARK_CHARS + this.reassertIntervalMs = opts.reassertIntervalMs ?? PRODUCER_PAUSE_REASSERT_INTERVAL_MS + } + + /** Reports the current pending chars for a PTY. Fires pause exactly once at + * the HIGH crossing (re-asserted only after the failsafe interval) and + * resume exactly once when pending drains below LOW. */ + update(id: string, pendingChars: number): void { + const pausedAt = this.pausedAtByPty.get(id) + if (pausedAt === undefined) { + if (pendingChars > this.highWatermarkChars) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + return + } + if (pendingChars < this.lowWatermarkChars) { + this.pausedAtByPty.delete(id) + this.safeResume(id) + return + } + if ( + pendingChars > this.highWatermarkChars && + Date.now() - pausedAt >= this.reassertIntervalMs + ) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + } + + /** Resumes a PTY if it was paused. For teardown paths (exit, kill) where + * the pending bookkeeping is being dropped rather than drained. */ + release(id: string): void { + if (this.pausedAtByPty.delete(id)) { + this.safeResume(id) + } + } + + /** Resumes every paused PTY. For wholesale bookkeeping wipes (window + * destroyed) — a local PTY left paused here would stay wedged forever. */ + releaseAll(): void { + // Deleting the visited entry during Map key iteration is spec-safe. + for (const id of this.pausedAtByPty.keys()) { + this.release(id) + } + } + + isPaused(id: string): boolean { + return this.pausedAtByPty.has(id) + } + + // Why swallow: pause/resume are optimizations riding the terminal data + // path — a provider throw must never break delivery or exit handling. + private safePause(id: string): void { + try { + this.transport.pauseProducer(id) + } catch { + /* best-effort */ + } + } + + private safeResume(id: string): void { + try { + this.transport.resumeProducer(id) + } catch { + /* best-effort */ + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 407f1710436..66a0c1c0750 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9,6 +9,7 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const isWindowsHost = process.platform === 'win32' @@ -93,7 +94,11 @@ const { vi.mock('electron', () => ({ app: { isPackaged: true, - getPath: getPathMock + getPath: getPathMock, + getVersion: () => '0.0.0-test' + }, + powerMonitor: { + on: vi.fn() }, nativeTheme: { shouldUseDarkColors: true @@ -192,6 +197,11 @@ import { unregisterSshPtyProvider, getLocalPtyProvider } from './pty' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import * as livePtyGate from '../claude-accounts/live-pty-gate' import { @@ -237,6 +247,9 @@ describe('registerPtyHandlers', () => { const handlers = new Map unknown>() const mainWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -324,6 +337,9 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why: hidden-delivery gate state is module-level by design (PTY-keyed, + // not window-keyed); tests must not leak hidden bits across cases. + _resetHiddenRendererPtyDeliveryGateForTest() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel // unless removeHandler cleared it first. This catches a re-registration @@ -401,7 +417,21 @@ describe('registerPtyHandlers', () => { afterEach(() => { _resetWslCachesForTests() vi.useRealTimers() - unregisterSshPtyProvider('ssh-1') + // Why: sshProviders is module-level state; any id left registered leaks + // into later tests (pty:listSessions sweeps every registered provider). + for (const leakedConnectionId of [ + 'ssh-1', + 'ssh-a', + 'ssh-b', + 'ssh-expired-runtime', + 'ssh-fresh-fail', + 'ssh-reattach-1', + 'ssh-reattach-fail', + 'ssh-reattach-ok', + 'ssh-runtime-env' + ]) { + unregisterSshPtyProvider(leakedConnectionId) + } setLocalPtyProvider(new LocalPtyProvider()) if (savedProcessPlatform) { Object.defineProperty(process, 'platform', savedProcessPlatform) @@ -542,12 +572,21 @@ describe('registerPtyHandlers', () => { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' })) + const write = vi.fn() + const pauseProducer = vi.fn() + const resumeProducer = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null + let backgroundStreamHandler: + | ((payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) + | null = null + const getBufferSnapshot = vi.fn() setLocalPtyProvider({ spawn, - write: vi.fn(), + write, resize: vi.fn(), + pauseProducer, + resumeProducer, kill: vi.fn(), shutdown: vi.fn(), sendSignal: vi.fn(), @@ -564,6 +603,13 @@ describe('registerPtyHandlers', () => { return () => {} }), onReplay: vi.fn(() => () => {}), + onBackgroundStreamEvent: vi.fn( + (handler: (payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) => { + backgroundStreamHandler = handler + return () => {} + } + ), + getBufferSnapshot, onExit: vi.fn((handler: (payload: { id: string; code: number }) => void) => { exitHandler = handler return () => {} @@ -575,20 +621,29 @@ describe('registerPtyHandlers', () => { } as never) return { spawn, + write, + pauseProducer, + resumeProducer, + getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), - emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) + emitExit: (id: string, code = 0) => exitHandler?.({ id, code }), + emitDataGap: (id: string, droppedChars: number) => + backgroundStreamHandler?.({ id, kind: 'dataGap', droppedChars }) } } function getPtyAckDataListener(): ( event: unknown, - args: { id: string; charCount: number } + args: { id: string; charCount?: number; processedChars?: number } ) => void { const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData') if (!ackCall) { throw new Error('missing pty:ackData listener') } - return ackCall[1] as (event: unknown, args: { id: string; charCount: number }) => void + return ackCall[1] as ( + event: unknown, + args: { id: string; charCount?: number; processedChars?: number } + ) => void } function getPtySetActiveRendererPtyListener(): ( @@ -654,6 +709,32 @@ describe('registerPtyHandlers', () => { ) => void } + function getPtySetHiddenRendererPtyListener(): ( + event: unknown, + args: { id: string; hidden: boolean } + ) => void { + const hiddenCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setHiddenRendererPty' + ) + if (!hiddenCall) { + throw new Error('missing pty:setHiddenRendererPty listener') + } + return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void + } + + function getPtySetDeliveryInterestListener(): ( + event: unknown, + args: { id: string; interested: boolean } + ) => void { + const interestCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest' + ) + if (!interestCall) { + throw new Error('missing pty:setPtyDeliveryInterest listener') + } + return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void + } + /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, @@ -1632,6 +1713,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -1817,6 +1899,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3342,6 +3425,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn(() => 13), @@ -3375,11 +3459,16 @@ describe('registerPtyHandlers', () => { const result = await pendingSpawn daemon.emitData(result.id, 'daemon output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) daemon.emitExit(result.id, 0) expect(daemon.spawn).toHaveBeenCalledTimes(1) - expect(runtime.onPtyData).toHaveBeenCalledWith(result.id, 'daemon output', expect.any(Number)) + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'daemon output', + expect.any(Number), + 'daemon output'.length + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: result.id, data: 'daemon output', @@ -3396,76 +3485,6 @@ describe('registerPtyHandlers', () => { } }) - // Why: the unsent main->renderer backlog was only bounded by renderer ACKs. A - // background-throttled/frozen renderer (Win/Linux) stops ACKing while a busy - // pane keeps producing, so the per-pty pending string grew at raw PTY rate - // (MB->GB). The cap trims it to the most-recent span and flags droppedBacklog - // exactly once so the renderer rebuilds the dropped span from the main snapshot. - it('caps the unsent pending backlog and flags droppedBacklog once when the renderer never acks', async () => { - vi.useFakeTimers() - let seq = 0 - const runtime = { - setPtyController: vi.fn(), - registerPty: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn((_id: string, data: string) => { - seq += data.length - return seq - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-cap'), - registerPreAllocatedHandleForPty: vi.fn() - } - try { - registerPtyHandlers( - mainWindow as never, - runtime as never, - undefined, - undefined, - undefined, - undefined, - { awaitLocalPtyStartup: () => Promise.resolve() } - ) - const pendingSpawn = handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - sessionId: 'backlog-cap-session' - }) as Promise<{ id: string }> - await Promise.resolve() - const daemon = installObservableDaemonTestProvider() - rebindLocalProviderListeners() - const result = await pendingSpawn - - // Never ack: in-flight pins at the high-water and the flush gates, so the - // unsent backlog would grow unbounded. Emit far more than the 2 MB cap. - const HUGE = 'x'.repeat(5 * 1024 * 1024) - daemon.emitData(result.id, HUGE) - await vi.advanceTimersByTimeAsync(50) - - const dataSends = mainWindow.webContents.send.mock.calls.filter( - (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id - ) - expect(dataSends.length).toBeGreaterThan(0) - // Sanity bound only: with no acks the sent total is already gated by the - // pre-existing in-flight high-water caps, so this holds independent of the - // trim. The actual retained-backlog cap is proven by droppedBacklog below. - const totalChars = dataSends.reduce( - (sum, call) => sum + (call[1] as { data: string }).data.length, - 0 - ) - expect(totalChars).toBeLessThan(3 * 1024 * 1024) - // The trim signals the renderer exactly once, on the first emitted chunk. - expect((dataSends[0][1] as { droppedBacklog?: boolean }).droppedBacklog).toBe(true) - expect( - dataSends.filter( - (call) => (call[1] as { droppedBacklog?: boolean }).droppedBacklog === true - ) - ).toHaveLength(1) - } finally { - vi.useRealTimers() - } - }) - // Why: the cap and its flag must never fire in the common case (renderer keeps // up), so ordinary small output carries no droppedBacklog. it('does not flag droppedBacklog for ordinary small output under the cap', async () => { @@ -3519,6 +3538,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4216,6 +4236,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn() } @@ -4502,6 +4523,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_wrong'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4553,6 +4575,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4592,6 +4615,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5039,6 +5063,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5110,6 +5135,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5196,6 +5222,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5293,6 +5320,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5401,6 +5429,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5494,6 +5523,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5699,6 +5729,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5757,6 +5788,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5804,6 +5836,7 @@ describe('registerPtyHandlers', () => { it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5831,6 +5864,7 @@ describe('registerPtyHandlers', () => { }) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -6865,7 +6899,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('background output') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -6894,7 +6928,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'visible output' @@ -6904,7 +6938,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: false }) mockProc.emitData('\x1b[2Khidden-width redraw') setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6938,7 +6972,7 @@ describe('registerPtyHandlers', () => { // Reloaded page's dispatcher re-registers, releasing held sends (§1b). handleRendererDispatcherReady() mockProc.emitData('reload-gap output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6949,7 +6983,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7398,7 +7432,7 @@ describe('registerPtyHandlers', () => { resizePty(null, { id: spawnResult.id, cols: 72, rows: 24 }) setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('\x1b[2Khidden-resize redraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7409,7 +7443,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() resizePty(null, { id: spawnResult.id, cols: 80, rows: 24 }) mockProc.emitData('visible repaint') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7442,7 +7476,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) writePty(mainWindowIpcEvent, { id: spawnResult.id, data: 'x' }) mockProc.emitData('x') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7477,7 +7511,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7511,7 +7545,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7543,7 +7577,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${query}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${query}ready` @@ -7577,7 +7611,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${command}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${command}ready` @@ -7638,7 +7672,7 @@ describe('registerPtyHandlers', () => { expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'daemon-ready' @@ -7713,7 +7747,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') @@ -7753,7 +7787,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b[20;2Hredraw' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7809,7 +7843,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(largeOutput) expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: largeOutput @@ -7845,7 +7879,7 @@ describe('registerPtyHandlers', () => { } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', { id: spawnResult.id, @@ -7883,7 +7917,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: redraw }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7915,7 +7949,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${pendingOutput}redraw` @@ -7950,7 +7984,7 @@ describe('registerPtyHandlers', () => { secondProc.emitData('second-terminal-output') firstProc.emitData(`${firstChunk}${firstRemainder}`) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', { @@ -7996,7 +8030,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() firstProc.emitData('x'.repeat(600 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8021,7 +8055,7 @@ describe('registerPtyHandlers', () => { }) secondProc.emitData('second-terminal-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', { @@ -8060,6 +8094,775 @@ describe('registerPtyHandlers', () => { } }) + it('caps per-PTY pending output while the renderer is starved and heals via a droppedOutput sentinel', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the renderer in-flight window (512 KB) with no ACKs — the + // frozen/starved-renderer shape from the field reports. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Keep flooding well past the 2 MB per-PTY pending cap. Main must not + // buffer this unboundedly (previously: unbounded string concat). + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Later output while dropped must stay O(1), not start re-accumulating. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Renderer recovers and ACKs: the flush must deliver the droppedOutput + // sentinel so the pane repaints from the main-owned snapshot. + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '', + droppedOutput: true + }) + + // Fresh output after the sentinel flows normally again. + mainWindow.webContents.send.mockClear() + mockProc.emitData('back to normal') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: 'back to normal' + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('carves reply-eliciting queries out of a pending-cap bulk drop so probes survive', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window so everything after buffers in pendingData. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Flood past the pending cap WITH an embedded DSR probe — the program + // that wrote it blocks on the reply (the bench DSR timeout). + mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`) + // While latched, a later probe must also be carved out (bounded). + mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) + + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '\x1b[6n\x1b[0c', + droppedOutput: true + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('scales the pending-output cap with the scrollback setting', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + // 50k-row scrollback ⇒ 6 MB pending cap instead of the 2 MB floor. + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({ + terminalScrollbackRows: 50_000 + })) as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window with no ACKs, then buffer 3 MB — over + // the floor, under the scaled cap: it must be retained, not dropped. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot().pendingChars).toBeGreaterThan(3 * 1024 * 1024) + + // The scaled cap still bounds a runaway flood. + mockProc.emitData('z'.repeat(4 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('pauses the producer at the pending high watermark and resumes after drain', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + // Flood in 64KB chunks like a `yes`-style producer that honors pause — + // node-pty pause() stops the fd read, so a real producer stops emitting. + const chunk = 'x'.repeat(64 * 1024) + let chunks = 0 + while (provider.pauseProducer.mock.calls.length === 0 && chunks < 100) { + provider.emitData('flood-pty', chunk) + chunks++ + } + + // Pause fires exactly once, on the first chunk past the 256KB high + // watermark (the 5th 64KB chunk), not once per chunk. + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(provider.pauseProducer).toHaveBeenCalledWith('flood-pty') + expect(chunks).toBe(5) + // Bounded: main buffered at most HIGH + one chunk while paused. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 320 * 1024, + peakPendingChars: 320 * 1024 + }) + + // Drain to the renderer. Resume must fire exactly once — when pending + // drops below the 32KB low watermark — with no pause/resume flapping + // while pending crosses the 32-256KB hysteresis band. + vi.runAllTimers() + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingChars: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('resumes a paused producer when the PTY exits before draining', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Exit while pending is still above the low watermark: the exit path + // must release the pause instead of leaving a stale mark behind. + provider.emitExit('flood-pty', 0) + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) + + const DELIVERY_RESYNC_UNANSWERED_WARNING = + '[pty] delivery resync probe unanswered — renderer IPC unresponsive' + + function countResyncUnansweredWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { + return warnSpy.mock.calls.filter((call) => call[0] === DELIVERY_RESYNC_UNANSWERED_WARNING) + .length + } + + function getPtyDataSendCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:data' + ) + } + + function getDeliveryResyncProbeCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:requestDeliveryResync' + ) + } + + function getDeliveryResyncResponseListener(): ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void { + const responseCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:deliveryResyncResponse' + ) + if (!responseCall) { + throw new Error('missing pty:deliveryResyncResponse listener') + } + return responseCall[1] as ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void + } + + /** Saturates one PTY to its 512 KiB in-flight cap so delivery is fully + * gated for that PTY. Leaves 88 KiB pending and no timers scheduled. */ + async function spawnAndSaturateRendererDeliveryGate( + mockProc: ReturnType + ): Promise<{ id: string }> { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + return spawnResult + } + + it('self-heals lost ACKs when a later cumulative ACK arrives', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + expect(getPtyDataSendCalls()).toHaveLength(32) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + rendererInFlightPtyCount: 1 + }) + + // Every per-chunk ACK was lost, but the next ACK carries the renderer's + // full cumulative total — the debt clears without any timer or reset. + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('applies cumulative ACKs idempotently and ignores stale reordered totals', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024, + maxRendererInFlightCharsByPty: 256 * 1024 + }) + + // Replayed duplicate credits nothing further. + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + + // A stale reordered total can never move accounting backwards. + ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('tolerates mixed legacy delta and cumulative ACK payloads', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + // Legacy delta shape (no processedChars) still credits per chunk. + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // A cumulative total then supersedes without double-crediting the delta. + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('forwards only newly acknowledged cumulative bytes to provider ACK backpressure', async () => { + vi.useFakeTimers() + const acknowledgeDataEvent = vi.fn() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'cumulative-pty' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn((callback) => { + mockProc.proc.onData((data: string) => callback({ id: 'cumulative-pty', data })) + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('remote-output') + vi.advanceTimersByTime(8) + + // Why: cumulative totals are clamped to what main actually sent, and a + // replayed total must credit SSH/relay flow control with zero, not + // duplicate bytes. + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith( + 1, + 'cumulative-pty', + 'remote-output'.length + ) + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'cumulative-pty', 0) + } finally { + vi.useRealTimers() + } + }) + + it('probes for a delivery resync when data arrives for a fully gated PTY and reconciles on reply', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + // Only one probe may be outstanding at a time. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + // The reconciled gate lets the held pendingData flush again (one 2ms + // batch window = one 16KB slice). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('ignores resync replies with stale request ids', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + mockProc.emitData('stuck-output') + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId + 41, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears an unanswered resync probe, warns once per silent streak, and never mutates counters', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + vi.advanceTimersByTime(4_999) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + + vi.advanceTimersByTime(1) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(warnSpy).toHaveBeenCalledWith( + DELIVERY_RESYNC_UNANSWERED_WARNING, + expect.objectContaining({ + rendererInFlightChars: 512 * 1024, + pendingPtyCount: 1 + }) + ) + // No blind reset: counters and pending output are untouched. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + 'stuck-output'.length + }) + expect(getPtyDataSendCalls()).toHaveLength(32) + + // The cleared flag lets the next gated arrival probe again, but a + // still-silent renderer does not spam a second warn. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(2) + vi.advanceTimersByTime(5_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('clears resync probe state when the window is destroyed', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + let destroyed = false + const destroyableWindow = { + isDestroyed: () => destroyed, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } + + try { + registerPtyHandlers(destroyableWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + destroyableWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('stuck-output') + // The outstanding probe's hygiene timeout is the only remaining timer; + // the dispatcher-ready handshake already drained the pending flush. + expect(vi.getTimerCount()).toBe(1) + + destroyed = true + mockProc.emitData('post-destroy output') + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(60_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + // ── Renderer-initiated delivery health/heal (pty:reportRendererDeliveryState) ── + // Field wedge repro (v1.4.121-rc.0 snapshot): push delivery dead, invoke + // alive. The solicited-resync probe above rides push and can never be + // answered in that state; this invoke lane is the recovery that can. + + function reportRendererDeliveryState(args: { + receivedCharsByPty: Record + processedCharsByPty: Record + heal?: boolean + rendererPtyDataListenerCount?: number | null + }): { + inFlightTotalChars: number + inFlightPtyCount: number + msSinceLastAck: number | null + writtenOff?: { id: string; markerSeq?: number; writtenOffChars: number }[] + } { + const handler = handlers.get('pty:reportRendererDeliveryState') + if (!handler) { + throw new Error('missing pty:reportRendererDeliveryState handler') + } + return handler(null, args) as ReturnType + } + + it('reports delivery health over invoke without mutating any delivery state', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + // The field wedge in miniature: renderer received nothing, no ACK ever. + const health = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {} + }) + + expect(health).toMatchObject({ + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 1, + msSinceLastAck: null + }) + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('merges cumulative processed totals from a health report as a repair lane', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Lost-ACK variant: renderer processed everything; only the ACK + // messages vanished. A plain report (no heal) must drain the debt. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(health).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(health.writtenOff).toBeUndefined() + // Fully reopened gate drains one 16K slice per batcher tick (0/1/2 ms). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(35) + } finally { + vi.useRealTimers() + } + }) + + it('heals a dead push channel: writes off unreceived bytes and returns restore markers', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + // The 512 KiB the renderer provably never received is written off; the + // 88 KiB still pending is dropped because the snapshot restore covers + // everything at or before the marker (hidden-drop parity). + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + expect(healed).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: 0, + pendingDroppedChars: 88 * 1024 + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', + expect.objectContaining({ rendererPtyDataListenerCount: 1 }) + ) + + // Delivery is unwedged: fresh output flows to the renderer again. + mockProc.emitData('after-heal') + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('never writes off bytes the renderer received but has not parsed yet', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Parse backpressure, not a wedge: every byte arrived, ACK credit is + // deferred to the scheduler consume point and will still repay this. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('refuses a heal while main has seen a recent ACK', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 }) + + // Some pty still round-trips ACKs — whatever the renderer thinks, the + // channel is not dead, so a heal request must not destroy accounting. + const blocked = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(blocked.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // Once main-side ACK silence crosses the floor, the same heal proceeds. + // (The ACK freed a 16K window slot, so one more pending slice shipped + // during the advance — the write-off covers 512K un-received again.) + vi.advanceTimersByTime(10_000) + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + } finally { + vi.useRealTimers() + } + }) + + it('zeroes renderer in-flight delivery counters when the renderer lifecycle resets', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 1, + rendererInFlightChars: 512 * 1024 + }) + + handleRendererLoading() + + // Why: reload kills the renderer dispatcher that would have ACKed, so + // keeping the counters would gate PTYs in the fresh renderer forever. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0 + }) + // Main now holds sends until the replacement page confirms its dispatcher + // is installed; the lifecycle reset arms a bounded handshake watchdog. + expect(vi.getTimerCount()).toBe(1) + + mockProc.emitData('after-reload') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + + getPtyRendererDispatcherReadyListener()() + // One 2ms batch window releases the fresh page's held output. + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) + } finally { + vi.useRealTimers() + } + }) + it('forwards only actually in-flight bytes to provider ACK backpressure', async () => { vi.useFakeTimers() const acknowledgeDataEvent = vi.fn() @@ -8097,7 +8900,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() mockProc.emitData('remote-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'remote-like-pty', @@ -8148,7 +8951,7 @@ describe('registerPtyHandlers', () => { for (const proc of bulkProcs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8183,7 +8986,10 @@ describe('registerPtyHandlers', () => { data: 'a' }) interactiveProc.emitData(reserveChunk) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + // Why: the reserve-exhausted send stays gated, and the fully gated + // arrival now also emits one delivery resync probe (not pty:data). + expect(getPtyDataSendCalls()).toHaveLength(529) + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) } finally { vi.useRealTimers() } @@ -8214,7 +9020,7 @@ describe('registerPtyHandlers', () => { for (const proc of procs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8255,7 +9061,7 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < procs.length - 1; index++) { procs[index]!.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8264,13 +9070,18 @@ describe('registerPtyHandlers', () => { const activeIndex = procs.length - 1 procs[activeIndex]!.emitData('active-output') setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { - id: spawns[activeIndex]!.id, - data: 'active-output' - }) + // Why: the fully gated arrival also emits one delivery resync probe, so + // count pty:data sends rather than raw webContents.send calls. + expect(getPtyDataSendCalls()).toHaveLength(513) + expect(getPtyDataSendCalls()[512]).toEqual([ + 'pty:data', + { + id: spawns[activeIndex]!.id, + data: 'active-output' + } + ]) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ activeRendererPtyCount: 1, pendingPtyCount: procs.length - 1, @@ -8298,7 +9109,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() activeProc.emitData('x'.repeat(768 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8329,6 +9140,809 @@ describe('registerPtyHandlers', () => { } }) + describe('hidden renderer delivery gate', () => { + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'hidden output') + vi.advanceTimersByTime(50) + + // Model ingestion still ran — only renderer delivery was dropped. + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'hidden output', + expect.any(Number), + 'hidden output'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data chunk is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + + // Subsequent gated chunks drop silently — the marker is one-shot. + daemon.emitData(result.id, 'more hidden output') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length, + hiddenDeliveryDroppedChunks: 2, + pendingPtyCount: 0, + rendererInFlightChars: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('surfaces the hidden-yet-visible contradiction in the snapshot and warns on drop', async () => { + // Why: v1.4.124-rc.2.perf field snapshot — blank terminal with 2 ptys + // hidden-gated and 78MB dropped. The aggregate counts could not say + // whether the pane the user was staring at was one of them; this + // overlap counter + warn makes the next occurrence decisive. + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + + // The two renderer visibility signals contradict: the pane reports + // itself visible while the hidden-delivery gate still holds it. + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 1, + hiddenDeliveryDroppedChars: 'starved visible output'.length + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', + expect.objectContaining({ id: result.id, visible: true }) + ) + + // Unhiding resolves the contradiction. + setHidden(null, { id: result.id, hidden: false }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('embeds one-paste freeze diagnostics: per-pty table and breadcrumb history', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + const { diagnostics } = getPtyRendererDeliveryDebugSnapshot() + expect(diagnostics.appVersion).toBe('0.0.0-test') + expect(diagnostics.windowFocused).toBe(true) + expect(diagnostics.windowVisible).toBe(true) + const entry = diagnostics.perPty.find( + (candidate) => candidate.id === redactPtyIdForDiagnostics(result.id) + ) + expect(entry).toMatchObject({ + hidden: true, + visible: true, + inFlightChars: 0, + pendingChars: 0 + }) + // Why redaction is pinned here: daemon session ids embed worktree + // paths; the report must never carry the raw id. + expect(diagnostics.perPty.some((candidate) => candidate.id === result.id)).toBe(false) + const breadcrumbKinds = diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(breadcrumbKinds).toContain('gate-mark') + expect(breadcrumbKinds).toContain('hidden-drop-visible') + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps the interactive bypass gated for hidden PTYs', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + const setHidden = getPtySetHiddenRendererPtyListener() + + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + setHidden(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + + // A keystroke-sized redraw would take the immediate path when visible. + mockProc.emitData('\x1b[20;2Hredraw') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses the gate while renderer delivery interest is registered', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('sidecar bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'sidecar bytes' + }) + + setInterest(null, { id: spawnResult.id, interested: false }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('gated bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps delivery when the %s kill switch is off', async (_name, settings) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('still delivered') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'still delivered' + }) + } finally { + vi.useRealTimers() + } + }) + + it('drops queued pending data when a PTY is marked hidden', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('queued before hidden') + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + setHidden(null, { id: spawnResult.id, hidden: true }) + + // The queued bytes are model-owned; only the restore marker goes out. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('re-emits the restore marker on unhide and resumes delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a renderer reload can replace the view that latched + // restore-needed; unhide repeats the marker so the live view heals. + setHidden(null, { id: spawnResult.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + + mockProc.emitData('visible again') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible again' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit an unhide marker when nothing was dropped', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears gate state on PTY exit', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + + setHidden(null, { id: spawnResult.id, hidden: true }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1 + }) + + mockProc.emitExit(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a hidden remount so reveal still restores', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a hidden remount (tab move, parking handoff) re-marks the PTY + // without an unhide in between. The fresh view never saw the first + // marker, so re-marking must NOT erase the drop memory. + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + // Why daemon provider: it survives renderer reloads (the scenario + // under test) and keeps the LocalPtyProvider orphan-kill handler off + // this webContents, so 'did-finish-load' maps to the gate reset only. + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'dropped while hidden') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Renderer reload: hidden marks die with the old renderer, but the + // dropped bytes were never restored — memory must survive. + reloadHandlers[0]() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0 + }) + + // The reloaded pane's first sync re-marks hidden, then reveals. + setHidden(null, { id: result.id, hidden: true }) + setHidden(null, { id: result.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'unhide', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + // A sidecar holds interest, so hidden bytes still flow. + setInterest(null, { id: result.id, interested: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'sidecar bytes') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith( + 'pty:data', + expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) + ) + + // Why: the renderer reload killed the sidecar's ref count without a + // release IPC — the leaked hold must not force-feed the PTY forever. + reloadHandlers[0]() + mainWindow.webContents.send.mockClear() + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'gated after reload') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + }) + + describe('hidden-at-spawn mark (initiallyHidden)', () => { + // terminal-query-authority.md §races: the renderer declares hidden-at- + // spawn so main marks the PTY before its first byte — the spawn-time + // query window where neither side replied (the non-codex DA1 loss) is + // closed by the gate + responder owning queries from byte one. + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => { + vi.useFakeTimers() + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) as Promise<{ id: string }> + // Let the handler run up to the awaited provider.spawn. + await Promise.resolve() + mainWindow.webContents.send.mockClear() + + // Daemon PTYs can emit prompt bytes before spawn() resolves — the + // pre-spawn mark must already gate them. + expect(isHiddenRendererPty('daemon-session')).toBe(true) + daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') + vi.advanceTimersByTime(50) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'daemon-session', + 'pre-spawn prompt\x1b[c', + expect.any(Number), + 'pre-spawn prompt\x1b[c'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'daemon-session', + reason: 'hidden-drop', + markerSeq: 42 + }) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('clears the pre-spawn hidden mark when the spawn fails', async () => { + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockRejectedValue(new Error('spawn exploded')) + registerPtyHandlers(mainWindow as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) + ).rejects.toThrow('spawn exploded') + + // A later visible attach reusing this session id must not start gated. + expect(isHiddenRendererPty('daemon-session')).toBe(false) + }) + + it('marks local PTYs hidden after spawn, before their first data task', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + initiallyHidden: true + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(true) + mockProc.emitData('first chunk') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(false) + mockProc.emitData('visible output') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { + // End-to-end through a REAL runtime: spawn-marked → first chunk dropped + // → runtime emulator parses the query → reply written to the provider + // input path (the renderer never saw the bytes; main is the answerer). + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + } as never) + + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + })) as { id: string } + + daemon.emitData(result.id, '\x1b[c') + // Settle the per-PTY emulator writeChain (and the reply it forwards). + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c') + }) + }) + + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + // 3 MB in one starved pending entry: the scrollback-scaled cap (2 MB at + // default settings) drops the buffered bytes to O(1) memory. One + // out-of-band restore marker fires; the droppedOutput sentinel then + // routes the pane through the main-owned snapshot repaint. + mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'pending-cap' + }) + + // A second overflow before the entry drains must not re-mark. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])( + 'keeps the pending cap active without a restore marker when the %s kill switch is off', + async (_name, settings) => { + // Why: the scrollback-scaled pending cap ships independently of the gate + // (#7150) — the droppedOutput sentinel repaints the pane from the + // main-owned snapshot even with the model/view kill switches off. Only the + // gate's out-of-band pty:modelRestoreNeeded marker is switch-scoped. + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + mockProc.emitData('x'.repeat(3 * 1024 * 1024)) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.anything() + ) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + } + ) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -8353,7 +9967,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('stale redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'stale redraw' @@ -8500,6 +10114,34 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) + it('silently drops writes to a live PTY after ownership loss until pty:listSessions rebuilds it (frozen-terminal repro)', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const write = getPtyWriteListener() + + write(mainWindowIpcEvent, { id: result.id, data: 'alive' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('alive') + + // Field failure shape (Discord #performance / #2836): a pane can keep + // rendering with a ptyId whose ownership entry is gone while the provider + // still holds the live PTY — every keystroke then vanishes with no error, + // no log, and no signal back to the renderer. + deletePtyOwnership(result.id) + write(mainWindowIpcEvent, { id: result.id, data: 'dropped' }) + expect(mockProc.proc.write).not.toHaveBeenCalledWith('dropped') + + // pty:listSessions rebuilds ownership from provider sessions — the + // revival lever the frozen-pane e2e probes depend on. + await handlers.get('pty:listSessions')!(null, undefined) + write(mainWindowIpcEvent, { id: result.id, data: 'revived' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('revived') + }) + it('chunks large acknowledged pty writes before provider writes', async () => { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -8583,6 +10225,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -8848,6 +10491,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8888,6 +10532,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8896,16 +10541,23 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // Why both: a reload fires the hidden-delivery gate reset AND the orphan + // cleanup; invoke every registered listener like a real did-finish-load. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => { + for (const handler of didFinishLoadHandlers) { + handler() + } + } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] @@ -8918,6 +10570,9 @@ describe('registerPtyHandlers', () => { it('removes the previous orphan-cleanup listener from its original webContents', () => { const firstWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8926,6 +10581,9 @@ describe('registerPtyHandlers', () => { } const secondWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8934,10 +10592,12 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + // Two listeners on the first (LocalPtyProvider) window: the renderer-gate + // reset and the orphan cleanup. + const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + ) + expect(firstWindowLoadHandlers).toHaveLength(2) setLocalPtyProvider({ spawn: vi.fn(), @@ -8952,13 +10612,20 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( - 'did-finish-load', - didFinishLoad - ) + // Every first-window load listener was detached from its webContents. + for (const [, handler] of firstWindowLoadHandlers) { + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + handler + ) + } + // The non-Local provider keeps orphan cleanup off the second window — + // only the renderer-gate reset listener remains. expect( - secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') - ).toBe(false) + secondWindow.webContents.on.mock.calls.filter( + ([eventName]) => eventName === 'did-finish-load' + ) + ).toHaveLength(1) }) // Why (#5787): a crash/freeze-recovery reload re-fires did-finish-load on the @@ -8995,10 +10662,14 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we — + // otherwise the suppression assertion passes vacuously without reaching the sweep. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9006,8 +10677,8 @@ describe('registerPtyHandlers', () => { // Without the guard the second load would sweep this PTY as a prior-generation // orphan. Under recovery-in-flight neither load may touch it. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() @@ -9050,9 +10721,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9060,8 +10734,8 @@ describe('registerPtyHandlers', () => { // First load only advances the generation; the second sees this PTY as a // prior-load orphan. With the flag false the guard must NOT suppress the sweep. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) @@ -9092,9 +10766,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // Fire ALL did-finish-load listeners (gate reset + orphan sweep), as a + // real reload does — the sweep listener is the one under test. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9109,7 +10786,7 @@ describe('registerPtyHandlers', () => { // Advance the generation without sweeping (recovery-in-flight), then spawn a // second PTY so the two live in different load generations. - didFinishLoad?.() + didFinishLoad() spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9122,7 +10799,7 @@ describe('registerPtyHandlers', () => { }) const ptyB = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string } - didFinishLoad?.() + didFinishLoad() expect(killSpyA).not.toHaveBeenCalled() expect(killSpyB).not.toHaveBeenCalled() @@ -9384,6 +11061,7 @@ describe('registerPtyHandlers', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 42), serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ data: 'snapshot\r\n', cols: 120, @@ -9404,14 +11082,146 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) + // Why pendingDeliveryStartSeq === seq: the pending renderer-delivery + // queue is empty, so the renderer's post-restore duplicate window is + // empty too — low-seq live chunks (fresh seq domain) must not be + // dropped against the snapshot baseline. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, cwd: '/projects/restored', seq: 42, + pendingDeliveryStartSeq: 42, source: 'headless' }) }) + + it('uses the complete provider model after daemon stream thinning', async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless' + }) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(runtime.notePtyDataGap).toHaveBeenCalledWith('daemon-pty', 512) + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toEqual({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless', + // Bytes between main's current absolute seq and the daemon snapshot + // may still be queued on the stream socket and must dedupe on arrival. + pendingDeliveryStartSeq: 640 + }) + provider.emitExit('daemon-pty') + }) + + it("never paints main's incomplete tail when a required provider snapshot is unavailable", async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue(null) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toBeNull() + provider.emitExit('daemon-pty') + }) + + it('reports where the undelivered pending backlog starts alongside the snapshot', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => null), + getPtyOutputSequence: vi.fn(() => 2_472), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 100, + rows: 30, + seq: 2_472, + source: 'headless' + }) + } + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + // Starved pending entry: bytes ingested up to seq 2_472 but not yet + // flushed to the renderer — they can still arrive after the snapshot. + mockProc.emitData('frame-bytes') + + const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: spawnResult.id + })) as { pendingDeliveryStartSeq?: number } + + expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7564b09b750..0ca456f7607 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -12,12 +12,28 @@ import { type IpcMainInvokeEvent, type WebContents, ipcMain, - app + app, + powerMonitor } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from '../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { @@ -48,6 +64,7 @@ import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/s import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' +import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -96,6 +113,29 @@ import { import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { PtyProducerFlowController } from './pty-producer-flow-control' +import { + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenPtyDeliveryGateEnabled, + isHiddenRendererPty, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetHiddenRendererPtyDeliveryDebugCounters, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { + clearNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../runtime/terminal-model-query-authority' +import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' @@ -125,6 +165,10 @@ type FreshLocalFallbackProvider = IPtyProvider & { } const sshProviders = new Map() const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: producer flow control changes terminal physics — a flooding shell now +// blocks on write instead of buffering in main. Kill switch: flip this one +// line to disable pause/resume entirely without untangling the wiring. +const PRODUCER_FLOW_CONTROL_ENABLED = true // 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 @@ -1069,6 +1113,14 @@ export function clearProviderPtyState(id: string): void { pendingHiddenRendererResizeOutputPtys.delete(id) deliveredHiddenRendererResizeOutputPtys.delete(id) clearStartupTerminalColorQueryReplies(id) + // Why: every PTY teardown path funnels through here (local exit, daemon + // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must + // not outlive the PTY or a reused map entry could silently gate a new one. + clearHiddenRendererPtyDeliveryState(id) + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -1144,10 +1196,25 @@ export function setPtyOwnership(id: string, connectionId: string | null): void { // duplicate listeners that forward every event twice. let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null +let localBackgroundStreamUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null let rendererLifecycleResetWebContents: WebContents | null = null let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate's interest/hidden registries mirror renderer +// state (ref-counted holds, per-pane hidden marks). A reload or renderer +// crash destroys the owners without unregistering, so the registries are +// reset whenever the renderer process is replaced +// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory). +let rendererGateResetLoadHandler: (() => void) | null = null +let rendererGateResetGoneHandler: (() => void) | null = null +let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers +// closure but teardown funnels through module-scope clearProviderPtyState. +let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} +// Why: after daemon keep-tail thinning, main's mirror contains only the kept +// tail. Recovery must keep consulting the daemon's complete model until exit. +const providerSnapshotRequiredPtys = new Set() // Why: did-start-loading also fires for in-page subframe loads (e.g. the // sandboxed srcDoc iframes notebook HTML output renders), which are not renderer // lifecycle resets. A dedicated handler filters those via isLoadingMainFrame so a @@ -1180,6 +1247,18 @@ export type PtyRendererDeliveryDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a + * contradiction that should be zero; nonzero means the user may be staring + * at a pane main is deliberately starving (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the // leaked-delivery-accounting freeze this reset fixes; the count tracks how // many renderer lifecycle resets have run since launch. @@ -1192,6 +1271,30 @@ export type PtyRendererDeliveryDebugSnapshot = { rendererDispatcherReadyForcedCount: number } +// Why module scope: breadcrumb writers live both inside registerPtyHandlers +// (gate marks, heals) and outside it (renderer lifecycle resets). +const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +let lastPowerSuspendAtMs: number | null = null +let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume +// timestamps in the report let us line breadcrumbs up against the wake. +function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { pendingPtyCount: 0, pendingChars: 0, @@ -1206,6 +1309,14 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -1241,6 +1352,9 @@ function clearDidFinishLoadHandler(): void { } function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the history is load-bearing context for any freeze + // report ("did the user already reload before capturing?"). + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') // Why: renderer-owned hints die with the page; keep known-visibility state so // surviving daemon/SSH PTYs fail closed until the new renderer reports again. activeRendererPtys.clear() @@ -1293,6 +1407,23 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void webContents.on('destroyed', rendererLifecycleResetHandler) } +function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + rendererGateResetLoadHandler = null + rendererGateResetGoneHandler = null + rendererGateResetWebContents = null +} + // Why: the "Restart daemon" flow needs to detach listeners from the current // adapter *after* synthetic pty:exit events fan out (so the renderer receives // them) but *before* replaceDaemonProvider swaps in the new adapter (so the @@ -1301,8 +1432,10 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void export function unbindLocalProviderListeners(): void { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() localDataUnsub = null localExitUnsub = null + localBackgroundStreamUnsub = null } // ─── IPC Registration ─────────────────────────────────────────────── @@ -1353,12 +1486,15 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. @@ -1434,10 +1570,12 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean - /** Set once this pty's unsent backlog was trimmed past the cap; rides the - * next payload so the renderer rebuilds the dropped span from the main - * headless snapshot (see appendPendingPtyData / dataCallback). */ - droppedBacklog?: boolean + // Why droppedOutput (not main's droppedBacklog trim): this branch bounds + // the unsent backlog with the O(1) drop-to-sentinel + query-salvage + + // snapshot-restore mechanism below, which strictly supersedes main's + // #7630 keep-2MB-tail trim — carrying both would race two cap policies + // over the same buffer. + droppedOutput?: true } type PtyDataPayload = { @@ -1446,37 +1584,70 @@ export function registerPtyHandlers( seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } const pendingData = new Map() - const rendererInFlightCharsByPty = new Map() + // Why: one restore marker per overflow episode — cleared when the entry + // fully drains so a later overflow re-marks the renderer exactly once. + const pendingOverflowMarkedPtys = new Set() + // Why: TCP-style cumulative delivery accounting. Relative in-flight counters + // make every lost ACK a permanent debt; monotonic sent/acked totals self-heal + // as soon as any later ACK (or resync reply) reports the renderer's full + // processed count. + type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null + } + const rendererDeliveryAccountingByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 - const PTY_BATCH_INTERVAL_MS = 8 + let pendingDroppedChars = 0 + let deliveryResyncRequestSerial = 0 + let deliveryResyncOutstandingRequestId: number | null = null + let deliveryResyncTimer: ReturnType | null = null + let deliveryResyncUnansweredWarnLogged = false + let lastAckReceivedAtMs: number | null = null + // Why 2ms: pairs with the daemon stream batcher (see + // daemon-stream-data-batcher.ts) — both hops charged an expected + // half-window per chunk; 2ms keeps flood coalescing at negligible IPC + // overhead while cutting the pipeline's fixed latency tax. + const PTY_BATCH_INTERVAL_MS = 2 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: while the renderer cannot receive (frozen, starved, mid-reload), a + // chatty PTY used to grow its pendingData string without bound — main-process + // heap ballooning that a renderer reload cannot clear (main's #7630 fixed the + // same Win/Linux background-throttled-renderer leak with a 2MB tail trim; + // this branch's sentinel mechanism supersedes it). Beyond this cap the + // buffered bytes are dropped and the pane heals from the main-owned buffer + // snapshot via the droppedOutput sentinel (renderer hidden-output restore). + // Why read settings live: the cap scales with the user's scrollback setting + // so power users don't lose lines their scrollback would have retained. + const pendingDataCapChars = (): number => + terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) // Why: self-heal bound for the dispatcher-ready gate — if a reloaded page never // sends pty:rendererDispatcherReady (lost IPC), force sends back on after this // window so a dropped handshake can't itself become a permanent hold. const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - // Why: the in-flight caps above bound only SENT-but-unacked bytes; the unsent - // `pendingData` backlog is drained only when the renderer ACKs. A background- - // throttled/frozen renderer (Win/Linux — macOS disables throttling) stops - // ACKing while a busy pane keeps producing, so without this the per-pty queue - // grows at raw PTY throughput (MB->GB). Cap it (matching the daemon 2 MB - // pendingOutput and renderer 2 MB scheduler caps); the trimmed span is - // recovered from the main headless buffer via the renderer's hidden-output - // snapshot restore, flagged by droppedBacklog — so no output is lost. - const PENDING_DATA_MAX_CHARS = 2 * 1024 * 1024 const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 + // Why: request/response hygiene only — this timeout never mutates delivery + // state. It clears the outstanding-probe flag so a later gated arrival can + // probe again, and logs once per silent streak for field diagnosis. + const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 + // Why: a heal write-off destroys delivery accounting; require main to have + // seen zero ACKs for this long too, independent of the renderer's own + // two-silent-ticks evidence, before believing the channel is dead. + const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1502,12 +1673,75 @@ export function registerPtyHandlers( let rendererPtyDispatcherReady = false let dispatcherReadyWatchdogTimer: ReturnType | null = null - function getMaxMapValue(values: Iterable): number { - let max = 0 - for (const value of values) { - max = Math.max(max, value) + // Why: watermark-driven producer pause/resume (terminal-performance + // initiative §5). Signal source is per-PTY pendingData only — renderer + // in-flight is already bounded by the ACK window above, while pendingData + // is what grows without bound when the renderer cannot keep up. Providers + // without support (SSH, legacy daemon protocol) surface no pauseProducer + // and the call chain no-ops; the pending cap still bounds memory then. + const producerFlowControl = new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }) + + function updateProducerFlowControl(id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return } - return max + producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) + } + + // Why this exists: hidden ptys are exempt from pendingData flow control + // (their bytes are dropped after model ingestion, so pendingData never + // grows), which let background agents run 100MB+ ahead of main in the + // daemon's stream-socket buffer and bury the visible pane's echo. The + // provider transport keep-tail thins backgrounded ptys' monitoring stream + // under backlog; this sync tells it which ptys qualify. + // Why keyed on the visibility registry (NOT gate marks or gate-effective + // shouldDrop): delivery claims and raw-byte sidecars describe transport + // ownership, while thinning asks the semantic question "does any visible + // view show this PTY?" Remote view subscribers (mobile/web live terminals) + // consume raw bytes from main's fan-out, so their presence vetoes thinning + // outright. Dedupe keeps visibility-sync churn off the wire; `?? false` + // also swallows the initial + // not-background state. + const backgroundedDeliverySyncByPty = new Map() + function syncPtyBackgroundedDelivery(id: string, caller: string): void { + const background = + rendererPtyIsKnownHidden(id) && !(runtime?.hasRemoteTerminalViewSubscriber(id) ?? false) + if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + backgroundedDeliverySyncByPty.set(id, background) + provider.setPtyBackgrounded(id, background) + } + clearBackgroundedDeliverySyncForPty = (id: string) => { + backgroundedDeliverySyncByPty.delete(id) + } + if (runtime) { + runtime.onRemoteTerminalViewPresenceChanged = (id) => + syncPtyBackgroundedDelivery(id, 'remote-view') + } + function resyncBackgroundedDeliveriesAfterGateReset(): void { + for (const id of backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(id, 'gate-reset') + } + } + + function getRendererInFlightCharsForPty(id: string): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 } function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { @@ -1518,13 +1752,38 @@ export function registerPtyHandlers( pendingChars += chars maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: the two renderer visibility signals must agree; a pty both + // hidden-gated and reported visible means main is starving a pane the + // user can see (v1.4.124-rc.2.perf blank-terminal field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } return { pendingPtyCount: pendingData.size, pendingChars, maxPendingCharsByPty, - rendererInFlightPtyCount: rendererInFlightCharsByPty.size, + rendererInFlightPtyCount, rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()), + maxRendererInFlightCharsByPty, activeRendererPtyCount: activeRendererPtys.size, flushScheduled: flushTimer !== null, peakPendingChars, @@ -1532,6 +1791,11 @@ export function registerPtyHandlers( peakRendererInFlightChars, peakMaxRendererInFlightCharsByPty, ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(), rendererLifecycleResetCount, lastLifecycleResetClearedChars, rendererPtyDispatcherReady, @@ -1539,6 +1803,81 @@ export function registerPtyHandlers( } } + const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 + + // Built only when the debug snapshot is actually read — never on the data + // path. Aggregate counters can't say WHICH pty is wedged or WHEN the state + // arose; this per-pty table + both-process breadcrumb history can. + function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Hidden/visible/active set members are included even with no accounting + // entry: a pty gated before its first byte is exactly the wedge case the + // table must surface. + const ids = new Set([ + ...rendererDeliveryAccountingByPty.keys(), + ...pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !mainWindow.isDestroyed() + return { + appVersion: app.getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? mainWindow.isFocused() : null, + windowVisible: windowAlive ? mainWindow.isVisible() : null, + windowMinimized: windowAlive ? mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } + } + + // Why rate-limited: the contradiction persists chunk after chunk while + // latched; one line per minute keeps field logs readable but present. + let lastHiddenDropContradictionWarnAtMs = 0 + function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats itself, + // and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id, + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + function recordPtyRendererDeliveryPressure(): void { // Why: this fires on every PTY delivery event (per send, per flush, per // onData append). Update the four diagnostic peaks directly instead of @@ -1555,9 +1894,18 @@ export function registerPtyHandlers( peakPendingChars = Math.max(peakPendingChars, pendingChars) peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, maxPendingCharsByPty) peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) + // Why derived per entry: this branch tracks cumulative sent/acked totals + // (TCP-style), not a per-pty in-flight map — in-flight is the difference. + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } peakMaxRendererInFlightCharsByPty = Math.max( peakMaxRendererInFlightCharsByPty, - getMaxMapValue(rendererInFlightCharsByPty.values()) + maxRendererInFlightCharsByPty ) } @@ -1568,6 +1916,8 @@ export function registerPtyHandlers( peakRendererInFlightChars = 0 peakMaxRendererInFlightCharsByPty = 0 ackGatedFlushSkipCount = 0 + pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() recordPtyRendererDeliveryPressure() } resetRendererDeliveryAccountingForLifecycleReset = () => { @@ -1577,9 +1927,15 @@ export function registerPtyHandlers( // which are fed before the pendingData append so they always superset it. lastLifecycleResetClearedChars = rendererInFlightTotalChars rendererLifecycleResetCount += 1 - rendererInFlightCharsByPty.clear() + // Why: pending bytes and outstanding credits belonged to the dead page. + // Release producer pauses before clearing them so no shell stays wedged. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 pendingData.clear() + pendingOverflowMarkedPtys.clear() // Why: the reloading page's pty:data listener is gone until it re-registers // and re-sends the handshake; hold sends until then so the boot window can't // re-pin the gate with bytes dropped into a listener-less page. @@ -1630,8 +1986,7 @@ export function registerPtyHandlers( id: string, data: string, startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - droppedBacklog?: boolean + containsBackgroundOutput: boolean | undefined ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { @@ -1641,9 +1996,6 @@ export function registerPtyHandlers( if (containsBackgroundOutput === true) { payload.background = true } - if (droppedBacklog === true) { - payload.droppedBacklog = true - } return payload } @@ -1660,15 +2012,147 @@ export function registerPtyHandlers( const ptyLimit = PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return ( - (rendererInFlightCharsByPty.get(id) ?? 0) < ptyLimit && - rendererInFlightTotalChars < totalLimit + return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit + } + + // Why: max-merge on cumulative totals is idempotent and reorder-tolerant — + // a replayed or out-of-order ACK can never double-credit, and a lost ACK + // self-heals when any later ACK reports the full processed count. Returns + // the newly acknowledged delta so provider (SSH/daemon) backpressure is only + // credited for bytes main actually tracked in flight, never negative. + function applyCumulativeAck(id: string, processedChars: number): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) + return acknowledged + } + + function clearDeliveryResyncProbe(): void { + deliveryResyncOutstandingRequestId = null + if (deliveryResyncTimer) { + clearTimeout(deliveryResyncTimer) + deliveryResyncTimer = null + } + } + + // Why: event-triggered verified-state recovery. Data arriving for a fully + // gated PTY is the deterministic signal that delivery may be stuck on lost + // ACKs (e.g. dropped across a system suspend); ask the renderer for its + // authoritative processed totals instead of resetting on a wall-clock guess. + function requestDeliveryResyncForGatedPty(): void { + if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { + return + } + deliveryResyncRequestSerial += 1 + const requestId = deliveryResyncRequestSerial + deliveryResyncOutstandingRequestId = requestId + deliveryResyncTimer = setTimeout(() => { + if (deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe() + // Why: no state mutation on timeout — a renderer that cannot answer has + // dead IPC, and only a reload cures that. Log once per silent streak so + // field diagnosis is captured without spamming every probe cycle. + if (deliveryResyncUnansweredWarnLogged) { + return + } + deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + deliveryResyncTimer.unref?.() + mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) + } + + // Why: bytes sent but never counted received by the renderer after a + // confirmed wedge are gone — no ACK message can ever repay them (unlike a + // lost ACK, which any later cumulative total heals). Write the debt off and + // hand back restore markers so panes repaint from the main-owned snapshot; + // the caller routes them locally because push markers cannot arrive. + function writeOffLostRendererDelivery( + report: PtyRendererDeliveryStateReport + ): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip a parse-pending window: received-but-unparsed bytes sit alive + // in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at or before markerSeq comes from the + // snapshot (hidden-drop parity); flushing pre-marker bytes afterward + // would double-paint what the restore already covers. + const pending = pendingData.get(id) + if (pending) { + pendingDroppedChars += pending.data.length + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + } + const markerSeq = runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff } function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) - rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) + const accounting = rendererDeliveryAccountingByPty.get(id) + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) @@ -1701,6 +2185,26 @@ export function registerPtyHandlers( deliveredHiddenRendererResizeOutputPtys.delete(id) } + // Why: when main drops renderer delivery (hidden gate / pending cap), an + // explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to + // latch model-restore-needed. It must NOT ride pty:data: an in-band empty + // chunk is indistinguishable from a chunk fully consumed by renderer-side + // OSC-9999 stripping, which spuriously restored visible panes. + function sendModelRestoreNeededMarker( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ): void { + if (mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } + function getPendingPtyFlushEntries(): [string, PendingPtyData][] { const entries = Array.from(pendingData.entries()) const active: [string, PendingPtyData][] = [] @@ -1715,60 +2219,106 @@ export function registerPtyHandlers( return [...active, ...background] } - // Why: bound the unsent backlog to the most-recent PENDING_DATA_MAX_CHARS, - // advancing startSeq by the dropped-char count (same arithmetic flushPendingData - // uses when it slices) so the remaining tail's seq stays correct. Flags - // droppedBacklog so the next payload tells the renderer to rebuild the dropped - // span from the main headless snapshot. A no-op when under the cap (the common - // case: the renderer is ACKing and pendingData stays tiny). - function capPendingPtyData(pending: PendingPtyData): PendingPtyData { - if (pending.data.length <= PENDING_DATA_MAX_CHARS) { + const pendingDataDropWarnedPtys = new Set() + + // Why capped: the drop path guarantees O(1) memory per PTY; salvaged query + // bytes are tiny (a DSR probe is 4 chars) and anything past the cap means a + // pathological stream, where degrading to the plain sentinel is fine. + const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + + // Why: a bulk drop must not swallow reply-eliciting queries embedded in the + // flood (DSR 6n / CPR, DA1/DA2, DECRQM, OSC 10/11 probes). The program that + // wrote them blocks on the reply (the bench DSR timeout). Carve just the + // query bytes out and let them ride the droppedOutput sentinel — content is + // healed by the snapshot restore, so replies cannot double-fire. + function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + } + + function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { + const capChars = pendingDataCapChars() + if (pending.droppedOutput === true || pending.data.length <= capChars) { return pending } - const trimmed = pending.data.slice(pending.data.length - PENDING_DATA_MAX_CHARS) - const droppedChars = pending.data.length - trimmed.length - const next: PendingPtyData = { data: trimmed, droppedBacklog: true } - if (typeof pending.startSeq === 'number') { - next.startSeq = pending.startSeq + droppedChars + if (!pendingDataDropWarnedPtys.has(id)) { + pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning — drop frequency and size decide + // whether the cap is too small (issue #2836 / #7017). No pty id: session + // ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) } - if (pending.containsBackgroundOutput === true) { - next.containsBackgroundOutput = true + // Why: with the hidden-delivery gate rolled out, the model snapshot can + // recover the dropped middle — emit the out-of-band restore marker once + // per overflow episode alongside the droppedOutput sentinel so a fresh + // or reloaded view latches restore too. + if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { + pendingOverflowMarkedPtys.add(id) + sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + } + pendingDroppedChars += pending.data.length + // Why no trimmed content tail: a mid-stream gap would silently corrupt + // the pane. The droppedOutput sentinel routes the pane through + // hidden-output restore, which repaints from the authoritative main-owned + // buffer and realigns with the live stream by sequence. Only carved-out + // query bytes ride along so their replies survive the drop. + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true } - return next } function appendPendingPtyData( + id: string, existing: PendingPtyData | undefined, data: string, startSeq: number | undefined, preservesSeq: boolean, containsBackgroundOutput: boolean ): PendingPtyData { + // Why: once over the cap, stay dropped at O(1) memory until the renderer + // can receive again — the restore sentinel supersedes any interim bytes. + // Queries arriving while latched still get carved out (bounded) so their + // replies survive the whole drop episode, not just the first burst. + if (existing?.droppedOutput === true) { + if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return existing + } + const salvaged = extractDroppedPtyQueryBytes(data) + return salvaged ? { ...existing, data: existing.data + salvaged } : existing + } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput - // Carry a prior drop flag forward until it actually rides an outgoing payload. - const inheritedDropped = existing?.droppedBacklog === true - const base: PendingPtyData = { data: '' } if (!preservesSeq) { - base.data = (existing?.data ?? '') + data - } else if (!existing) { - base.data = data - if (typeof startSeq === 'number') { - base.startSeq = startSeq - } - } else { - base.data = existing.data + data - if (typeof existing.startSeq === 'number') { - base.startSeq = existing.startSeq - } + return dropOversizedPendingPtyData(id, { + data: (existing?.data ?? '') + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (nextContainsBackgroundOutput) { - base.containsBackgroundOutput = true + if (!existing) { + return dropOversizedPendingPtyData(id, { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (inheritedDropped) { - base.droppedBacklog = true + const next: PendingPtyData = { + data: existing.data + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) } - return capPendingPtyData(base) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(id, next) } function schedulePendingDataFlush(delayMs: number): void { @@ -1809,8 +2359,13 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { + // Why: the bookkeeping is being wiped, so no future drain can ever + // resume these producers — release them now or local shells wedge. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() @@ -1822,15 +2377,39 @@ export function registerPtyHandlers( if (!rendererPtyDispatcherReady) { return } + const settings = getSettings?.() let writes = 0 for (const [id, pending] of getPendingPtyFlushEntries()) { if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { break } + // Why: hidden-gated bytes are dropped, never re-queued — the model + // already ingested them; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + } + continue + } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { continue } pendingData.delete(id) + if (pending.droppedOutput === true) { + updateProducerFlowControl(id) + // Why: the buffered bytes were dropped at the pending cap; tell the + // renderer so the pane repaints from the main-owned buffer snapshot + // instead of continuing a stream with a silent gap. data carries only + // the carved-out query bytes (see extractDroppedPtyQueryBytes). + sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true }) + writes++ + continue + } const { data } = pending const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) @@ -1843,19 +2422,13 @@ export function registerPtyHandlers( nextPending.containsBackgroundOutput = true } pendingData.set(id, nextPending) + } else { + pendingOverflowMarkedPtys.delete(id) } - // Why: the drop flag rides the first emitted chunk (renderer restore is - // idempotent) and is intentionally not copied onto `remaining` above, so a - // single backlog trim signals the renderer exactly once. + updateProducerFlowControl(id) sendPtyDataToRenderer( id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.droppedBacklog - ) + makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput) ) writes++ } @@ -1913,25 +2486,40 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so + // the renderer knows the span was dropped (same as the flush loop). + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: remaining.data, + droppedOutput: true + }) + } else { + sendPtyDataToRenderer( payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.droppedBacklog + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) ) - ) + } pendingData.delete(payload.id) } + // Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY) + // rather than leave a stale paused mark behind for a reused id. + producerFlowControl.release(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) ) - rendererInFlightCharsByPty.delete(payload.id) + // Why: the renderer also drops its cumulative total on pty:exit, so a + // reused id restarts aligned at zero on both sides. + rendererDeliveryAccountingByPty.delete(payload.id) recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', payload) } @@ -1962,6 +2550,35 @@ export function registerPtyHandlers( const bindProviderListeners = (): void => { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() + + // Keep-tail thinning facts from the daemon, in byte order with onData. + // The marker flips scan authority for the four transient-fact scanners; + // a gap resets main's cross-chunk parse state and forces the renderer to + // restore from the model snapshot (same seq-guard path as hidden drops) + // in case any view — eager buffer included — was receiving bytes. + localBackgroundStreamUnsub = + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(payload.id) + ) + return + } + runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null // Why: LocalPtyProvider routes data to the runtime via configure().onData, // but daemon-backed providers don't have configure(). Without this, daemon @@ -1974,9 +2591,16 @@ export function registerPtyHandlers( localDataUnsub = localProvider.onData((payload) => { const outputSeq = isLocalProvider ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now()) + : runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + payload.sequenceChars ?? payload.data.length + ) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) - const preservesSeq = rendererData === payload.data + const preservesSeq = + rendererData === payload.data && + (payload.sequenceChars === undefined || payload.sequenceChars === payload.data.length) const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window @@ -1986,13 +2610,30 @@ export function registerPtyHandlers( clearTimeout(flushTimer) flushTimer = null } + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() return } + const settings = getSettings?.() + // Why: hidden-delivery gate — runtime ingestion above already consumed + // the chunk; gated renderer delivery is DROPPED (never queued) and the + // reveal path restores from the model snapshot via the seq guard. The + // drop sits before the interactive bypass so gated PTYs take neither + // the immediate nor the batched renderer path. + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } if (rendererData.length === 0) { return } @@ -2003,6 +2644,7 @@ export function registerPtyHandlers( } const existing = pendingData.get(payload.id) const pending = appendPendingPtyData( + payload.id, existing, rendererData, startSeq, @@ -2023,11 +2665,15 @@ export function registerPtyHandlers( // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + requestDeliveryResyncForGatedPty() pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() return } pendingData.delete(payload.id) + updateProducerFlowControl(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) clearFlushTimerIfIdle() // Why: agent TUIs redraw small prompt regions after every keystroke. // Waiting for the throughput batch timer adds visible input latency. @@ -2038,12 +2684,20 @@ export function registerPtyHandlers( ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedBacklog === true ? { droppedBacklog: true } : {}) + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) }) return } pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() + // Why: probe on data arrival, not on flush skips — new output for a + // fully gated PTY is the moment stuck delivery becomes observable. + if ( + !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) + ) { + requestDeliveryResyncForGatedPty() + } if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -2151,6 +2805,28 @@ export function registerPtyHandlers( }) } + // Why: a reload (did-finish-load) or renderer crash replaces the process + // that owned every delivery-interest hold and hidden mark; surviving + // daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats + // the gate) or stay gated against a renderer that never marked them. Drop + // memory is preserved — each pane's first sync re-marks/unmarks and the + // unmark path re-emits the restore marker for unrestored drops. + clearRendererGateResetHandlers() + rendererGateResetLoadHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + // Why: the daemon pacer must not keep throttling ptys whose hidden marks + // died with the renderer; the fresh renderer's first visibility sync + // re-marks the ones that are still hidden. + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetGoneHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetWebContents = mainWindow.webContents + mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) + mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -2465,6 +3141,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before any byte reaches the + // runtime emulator, so its DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } const relayResultId = getRelayPtyId(args.connectionId, result.id) const persistSshLease = (): void => { if (!store || !args.connectionId) { @@ -2534,6 +3222,10 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -2782,8 +3474,10 @@ export function registerPtyHandlers( cwd?: string | null lastTitle?: string seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { @@ -2791,13 +3485,61 @@ export function registerPtyHandlers( } const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) try { - return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows }) + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap, main's model contains only the retained tail. + // Returning it as a full snapshot would silently erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: sampled after serialize — every byte at or below snapshot.seq + // that can still reach the renderer sits in this pending queue. The + // renderer's post-restore dedupe bounds its duplicate window with it; + // without the bound a stale baseline silently swallows genuinely-new + // chunks whose seq domain sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } } catch { return null } } ) + // Why: with main holding side-effect authority the renderer no longer + // derives titles from replayed bytes on (re)attach. This title-only replay + // snapshot restores title state — never historical bells/completions (the + // no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) @@ -2833,6 +3575,11 @@ export function registerPtyHandlers( foreground?: unknown background?: unknown } + // Why: hidden-at-spawn declaration (terminal-query-authority.md + // §races) — the renderer knows at spawn time that no visible view + // will consume this PTY's bytes, so main marks it hidden BEFORE the + // first byte and the gate + model responder own spawn-time queries. + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -3222,6 +3969,20 @@ export function registerPtyHandlers( return await existingPaneSpawn.promise } const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null + const initiallyHidden = args.initiallyHidden === true + // Why pre-spawn for daemon-host sessions (id minted up front): daemon + // PTYs can emit prompt bytes before spawn() resolves, and the hidden + // mark must beat the first byte so the gate + model responder own + // spawn-time queries (terminal-query-authority.md §races). Other + // providers cannot emit until spawn resolves; the post-spawn mark + // below is byte-zero-safe for them. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + markHiddenRendererPty(preSpawnHiddenMarkId) + } let result: PtySpawnResult try { try { @@ -3240,6 +4001,11 @@ export function registerPtyHandlers( result = await provider.spawn(spawnOptions) spawnTiming.mark('provider_spawn') } catch (err) { + // Why: a failed spawn must not leave a stale hidden mark on a session + // id a later visible attach may reuse. + if (preSpawnHiddenMarkId !== null) { + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = @@ -3312,6 +4078,34 @@ export function registerPtyHandlers( reattach: result.isReattach ?? false }) ptyOwnership.set(result.id, args.connectionId ?? null) + if (initiallyHidden) { + // Why marked synchronously before any await below: local/SSH provider + // data events dispatch on later tasks, so this is still ahead of the + // first byte's delivery decision. Idempotent for daemon hosts already + // marked pre-spawn; the renderer's first visibility sync re-marks or + // unmarks (emitting the restore marker) through the Phase-4 path. + markHiddenRendererPty(result.id) + if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { + // Defense: never strand a mark on an id the provider renamed. + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + // Why after ptyOwnership.set: the provider lookup routes by + // ownership, and a hidden-spawned agent should be paceable from its + // first flood, not from its first visibility transition. + syncPtyBackgroundedDelivery(result.id, 'spawn') + } + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before the headless seed below, + // so the runtime emulator's DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { if (preSpawnStartupTerminalColorReplyPtyId) { @@ -3420,7 +4214,18 @@ export function registerPtyHandlers( ? { cols: result.snapshotCols, rows: result.snapshotRows } : undefined if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + // Why kitty flags ride seed metadata: the snapshot string omits + // them by design (renderer kitty reset stays authoritative), but + // the re-seeded emulator must answer hidden `CSI ? u` with the + // flags the still-running app pushed (terminal-query-authority.md). + runtime.seedHeadlessTerminal( + result.id, + result.snapshot, + seedSize, + typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {} + ) } else if ( result.coldRestore && typeof result.coldRestore.scrollback === 'string' && @@ -3453,6 +4258,13 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.( + result.id, + typeof args.command === 'string' ? args.command : null + ) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -3788,23 +4600,110 @@ export function registerPtyHandlers( // Why: renderer ACKs bound main→renderer terminal delivery without stopping // PTY ingestion. Agent/status consumers still see every chunk through the // provider/runtime path while background renderer writes wait their turn. - ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => { - const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0 - const current = rendererInFlightCharsByPty.get(args.id) ?? 0 - const acknowledged = Math.min(current, charCount) - const next = Math.max(0, current - charCount) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (next === 0) { - rendererInFlightCharsByPty.delete(args.id) - } else { - rendererInFlightCharsByPty.set(args.id, next) + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh + // diagnostic event, not a continuation of the last silent streak. + deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can + // pair an old renderer with a new main. Keyed by field presence. + const accounting = rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - recordPtyRendererDeliveryPressure() - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + ) + + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + deliveryResyncOutstandingRequestId === null || + args?.requestId !== deliveryResyncOutstandingRequestId + ) { + return + } + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + // Why: max-merge — the renderer's cumulative totals are authoritative + // for what it processed; reconciling them drains exactly the in-flight + // debt left by lost ACKs, nothing more. + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - }) + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0 snapshot, + // 2026-07-06) kills every main→renderer push channel while invoke stays + // alive, so the solicited-resync probe above can never be answered there. + // This is the same reconcile, ridden over the direction proven to work, plus + // a write-off lane for bytes the renderer provably never received. + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the + // resync response, so a heal is only reached when merging cannot drain. + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: the renderer's two silent ticks + // already argue for a wedge; requiring main to have seen no ACK either + // keeps a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + rendererInFlightTotalChars > 0 && + (lastAckReceivedAtMs === null || + Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = writeOffLostRendererDelivery(args) + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } + let inFlightPtyCount = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) // Why: the renderer sends this once its pty:data listener is live (per page // load / reload). Until it arrives, sends are held so boot-window bytes can't @@ -3864,6 +4763,75 @@ export function registerPtyHandlers( } else { visibleRendererPtys.delete(args.id) } + syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + if (args.hidden === true) { + markHiddenRendererPty(args.id) + // Why: bytes already queued for a newly hidden PTY are model-owned + // state; drop them now instead of holding them under ACK starvation. + // Reveal restores from the snapshot. + const pending = pendingData.get(args.id) + if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { + pendingData.delete(args.id) + updateProducerFlowControl(args.id) + pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + recordPtyRendererDeliveryPressure() + } + syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id) + syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a renderer reload or remount can replace the view that latched + // restore-needed from the first-drop marker. Re-emit on unhide so the + // (possibly fresh) visible view still pulls the model snapshot covering + // the dropped bytes. If the original view is still alive this can trigger + // a redundant second restore — accepted: a snapshot replay is cheap and + // idempotent, while a missed restore leaves a corrupt pane. + if (droppedWhileHidden) { + sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: the responder must never store a malformed + // palette — a wrong color reply breaks TUI theme detection worse than + // the documented silent-until-first-push behavior. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: explicit delivery-interest signal from renderer byte sidecars — + // any interest suppresses the hidden-delivery gate so + // raw-byte consumers keep receiving while the view is hidden or parked. + // Deliberately NOT synced to the daemon backlog pacer: interest consumers + // tolerate paced data, and interest churn must not un-pace a flood. + setRendererPtyDeliveryInterest(args.id, args.interested === true) }) ipcMain.removeAllListeners('pty:signal') diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 01697bcc6fe..7af303c72a3 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -5,6 +5,7 @@ const { applyElectronProxySettingsMock, browserWindowGetAllWindowsMock, handleMock, + onMock, previewGhosttyImportMock, previewWarpThemeImportMock, prepareLocalWorktreeRootsForReposMock, @@ -14,6 +15,7 @@ const { applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), + onMock: vi.fn(), previewGhosttyImportMock: vi.fn(), previewWarpThemeImportMock: vi.fn(), prepareLocalWorktreeRootsForReposMock: vi.fn(), @@ -22,7 +24,7 @@ const { vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, - ipcMain: { handle: handleMock }, + ipcMain: { handle: handleMock, on: onMock }, nativeTheme: { themeSource: 'system' } })) @@ -70,6 +72,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + onMock.mockClear() applyAppIconMock.mockClear() applyElectronProxySettingsMock.mockClear() applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) @@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => { expect(channels).toContain('settings:previewGhosttyImport') }) + it('answers the synchronous settings read with the persisted settings', () => { + // Why: panes can bind PTYs before async hydration; the side-effect + // authority kill switch needs the persisted value synchronously. + store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false }) + registerSettingsHandlers(store as never) + + const listener = onMock.mock.calls.find( + (call) => call[0] === 'settings:get-sync' + )?.[1] as (event: { returnValue: unknown }) => void + expect(listener).toBeTypeOf('function') + + const event = { returnValue: undefined as unknown } + listener(event) + expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) + }) + it('registers settings:previewWarpThemeImport handler', () => { registerSettingsHandlers(store as never) const channels = handleMock.mock.calls.map((call) => call[0]) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 4f3a4698c76..b59bf6add2e 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -67,6 +67,15 @@ export function registerSettingsHandlers( return store.getSettings() }) + // Why: terminal panes can bind PTYs before async settings hydration + // completes. The side-effect authority kill switch is consulted once at + // transport creation, so the renderer needs the persisted value + // synchronously or pre-hydration bindings would always pick main authority + // (terminal-side-effect-authority.md, migration switch). + ipcMain.on('settings:get-sync', (event) => { + event.returnValue = store.getSettings() + }) + ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = sanitizeRendererSettingsUpdate(args) // Why: Floating Workspace grants are trusted only when written by the diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 3fc96c897bf..1448f59d946 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -92,6 +92,8 @@ describe('LocalPtyProvider', () => { onExit: ReturnType write: ReturnType resize: ReturnType + pause: ReturnType + resume: ReturnType kill: ReturnType process: string pid: number @@ -138,6 +140,8 @@ describe('LocalPtyProvider', () => { }), write: vi.fn(), resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), kill: vi.fn(() => { exitCb?.({ exitCode: -1 }) }), @@ -896,6 +900,39 @@ describe('LocalPtyProvider', () => { }) }) + describe('producer flow control', () => { + it('pauses and resumes the node-pty process directly', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + provider.pauseProducer(id) + expect(mockProc.pause).toHaveBeenCalledTimes(1) + provider.resumeProducer(id) + expect(mockProc.resume).toHaveBeenCalledTimes(1) + }) + + it('is a no-op for unknown PTY ids', () => { + expect(() => { + provider.pauseProducer('nonexistent') + provider.resumeProducer('nonexistent') + }).not.toThrow() + expect(mockProc.pause).not.toHaveBeenCalled() + expect(mockProc.resume).not.toHaveBeenCalled() + }) + + it('swallows node-pty throws from a torn-down PTY', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + mockProc.pause.mockImplementation(() => { + throw new Error('read EIO') + }) + mockProc.resume.mockImplementation(() => { + throw new Error('read EIO') + }) + expect(() => { + provider.pauseProducer(id) + provider.resumeProducer(id) + }).not.toThrow() + }) + }) + describe('shutdown', () => { it('kills the PTY process', async () => { // Why: capture the spy reference before shutdown triggers onExit → diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 661b877abbf..941fe062997 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -842,6 +842,26 @@ export class LocalPtyProvider implements IPtyProvider { ptyProcesses.get(id)?.resize(cols, rows) } + // Why: node-pty pause() stops reading the pty master fd, so the kernel + // buffer fills and a flooding child blocks on write — true producer + // backpressure. Best-effort: a PTY torn down mid-call must never throw + // into the flow-control path. + pauseProducer(id: string): void { + try { + ptyProcesses.get(id)?.pause() + } catch { + /* PTY already destroyed */ + } + } + + resumeProducer(id: string): void { + try { + ptyProcesses.get(id)?.resume() + } catch { + /* PTY already destroyed */ + } + } + // Why: node-pty caches the last winsize it applied on the IPty handle, so its // cols/rows are the authoritative applied size (node-pty clamps invalid dims // and a resize on a dead handle is a no-op, neither of which the requested diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 367179338c2..be45aa04ff3 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -17,6 +17,9 @@ vi.mock('electron', () => ({ on: onMock, removeHandler: removeHandlerMock, removeAllListeners: removeAllListenersMock + }, + powerMonitor: { + on: vi.fn() } })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index c8e2680366b..24bd305bfbc 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,9 +21,38 @@ import type { CommitMessageDraftContext } from '../../shared/commit-message-gene import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' // ─── PTY Provider ─────────────────────────────────────────────────── +/** Notification-bearing fact a thinning transport detected while it held + * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */ +export type PtyTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type PtyBackgroundStreamEvent = + | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } + | { id: string; kind: 'transientFact'; fact: PtyTransientFact } + +export type PtyProviderBufferSnapshot = { + data: string + /** Authoritative normal buffer captured beside an alternate-screen frame. */ + scrollbackAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq: number + source: 'headless' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + pendingEscapeTailAnsi?: string +} + export type PtySpawnOptions = { cols: number rows: number @@ -83,6 +112,11 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the + * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags + * (terminal-query-authority.md §kitty). Never replayed into a renderer + * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + snapshotKittyKeyboardFlags?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** True when the reattached session uses the alternate screen buffer @@ -119,6 +153,36 @@ export type IPtyProvider = { hasPty?: (id: string) => boolean write(id: string, data: string): void resize(id: string, cols: number, rows: number): void + /** + * Producer-side flow control: stop/restart reading the underlying PTY so a + * flooding child blocks on write (kernel backpressure) instead of growing + * main-process buffers. Best-effort and optional — providers that cannot + * pause (SSH relay, legacy daemon protocols) omit these or no-op silently, + * and callers must keep functioning without them (the pending-output cap + * still bounds memory when pause is unavailable). + */ + pauseProducer?: (id: string) => void + resumeProducer?: (id: string) => void + /** + * Hidden-delivery hint: the renderer has no visible view for this PTY, so + * the provider's transport may keep-tail thin this PTY's monitoring stream + * under backlog (bytes nobody is watching must not bury a visible pane's + * echo). Best-effort and optional, like pauseProducer. + */ + setPtyBackgrounded?: (id: string, background: boolean) => void + /** + * Facts a thinning transport interleaves with onData, in byte order: + * scan-authority handoff markers, keep-tail gaps, and the transient facts + * (bell/command-finished/pr-link/2031) it detected in bytes it was allowed + * to drop. Only transports that thin implement it. + */ + onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void + /** Authoritative provider-owned model snapshot. Daemon providers expose this + * after their monitoring stream gaps; other providers may omit it. */ + getBufferSnapshot?: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise /** * The size the PTY has ACTUALLY applied, not the last size requested. * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), @@ -145,7 +209,9 @@ export type IPtyProvider = { listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> - onData(callback: (payload: { id: string; data: string }) => void): () => void + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void onExit(callback: (payload: { id: string; code: number }) => void): () => void } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 73330a14fa5..a9f03794db0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,6 +56,7 @@ import { } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -6475,6 +6476,654 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle when a completion title is coalesced with the next working title', async () => { + // Why: node-pty + the main batch window can coalesce "task done" and the + // next task's working title into one chunk. A last-title reader never + // sees the intermediate idle and the waiter hangs (issue #1083 class). + const runtime = createRuntime() + syncSinglePty(runtime) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const [terminal] = (await runtime.listTerminals()).terminals + const wait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101) + + await expect(wait).resolves.toMatchObject({ + handle: terminal.handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + // cursor-agent re-emits its bare native title on internal redraws while + // still working; it must not stomp the synthesized working title. + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: '⠋ Cursor Agent' + }) + }) + + it('clears a stale working title after 3s of title-less output', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + + await vi.advanceTimersByTimeAsync(3_000) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex' + }) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the stale-title timer when the PTY exits', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + runtime.onPtyExit(ptyId, 0) + + await vi.advanceTimersByTimeAsync(4_000) + + // The dead session keeps its factual last title — the disposed tracker's + // stale-title rewrite must not fire into the retained record. + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale-title timers isolated per PTY', async () => { + vi.useFakeTimers() + try { + const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' }, + { id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100) + // Only A receives title-less output, so only A's stale timer arms. + runtime.onPtyData(ptyA, 'output without a title\r\n', 101) + + await vi.advanceTimersByTimeAsync(3_000) + + const { terminals } = await runtime.listTerminals() + expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' }) + expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({ + title: 'Aider working' + }) + } finally { + vi.useRealTimers() + } + }) + + // ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ── + describe('terminal side-effect fact channel', () => { + function createSideEffectRuntime(): { + runtime: OrcaRuntimeService + batches: TerminalSideEffectBatch[] + } { + const batches: TerminalSideEffectBatch[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalSideEffects: (batch) => batches.push(batch) + }) + return { runtime, batches } + } + + it('emits one batched event per chunk with facts in byte order and attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07' + runtime.onPtyData('pty-1', chunk, 100) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + seq: chunk.length, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + paneKey: 'tab-1:1' + }) + expect(batches[0].replay).toBeUndefined() + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }, + { kind: 'agent-idle', title: 'Codex done' }, + { kind: 'bell' } + ]) + }) + + it('keeps per-PTY ordering across chunks and accumulates seq', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title']) + expect(batches[0].seq).toBeLessThan(batches[1].seq) + }) + + it('emits nothing for chunks without derived facts', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Plain output, a BEL-terminated non-title OSC split across chunks, and + // an Orca status payload: none of these is a title/bell/agent fact. + runtime.onPtyData('pty-1', 'plain output\r\n', 100) + runtime.onPtyData('pty-1', '\x1b]7;file://host', 101) + runtime.onPtyData('pty-1', '/tmp\x07', 102) + runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) + + expect(batches).toEqual([]) + }) + + it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', 'output without a title\r\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + // Timer facts fire outside a chunk, so each emits immediately — + // still strictly ordered per PTY. They carry staleWorkingTitleClear: + // the renderer must clear state without scheduling a task-complete + // notification main's unthrottled timer did not earn. + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('ingests synthetic title frames without touching the byte pipeline', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + // The synthesized spinner classifies as working — agent facts derive + // from synthetic frames the same as from real bytes. + { kind: 'agent-working' } + ]) + // Synthetic frames are fabricated by main: they must not advance the + // metered output sequence the renderer ACK budget is based on. + expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) + }) + + it('carries the synthetic permission BEL as a bell fact', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07') + + expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' }) + expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' }) + }) + + it('emits command-finished facts with best-effort exit codes across chunk splits', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '0\x07prompt $ ', 101) + runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'command-finished', exitCode: 130 }, + { kind: 'command-finished', exitCode: null } + ]) + }) + + it('emits pr-link facts once per URL with batch attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100) + runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101) + // Repeated URL: deduped per PTY, like the renderer byte detector. + runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches[0].facts).toEqual([ + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/43', + slug: { owner: 'acme', repo: 'orca' }, + number: 43 + } + } + ]) + }) + + it('emits 2031-subscribe facts across chunk splits', () => { + // Why: hidden-delivery-gated views never receive the bytes — this fact + // is their only signal to send the DECSET 2031 color-scheme reply. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b[?20', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '31h', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'visible content', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic frame (it no longer + // rides pty:data), so its serializer reports a stale title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('renderer') + expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') + }) + + it('prefers the tracked title over the headless emulator lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100) + // The hook-driven idle frame lands only in main's tracker — the + // emulator never sees fabricated bytes (invariant 5). + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('headless') + expect(snapshot?.lastTitle).toBe('Codex ready') + }) + + it('returns a title-only replay snapshot and never historical attention', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100) + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + ptyId: 'pty-1', + replay: true, + facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }] + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull() + }) + + it('drops the cursor-agent literal from record-fallback snapshots', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Simulate a record title restored by a path that bypassed the tracker + // (the tracker itself refuses to store the bare native title). + const records = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById + records.get('pty-1')!.lastOscTitle = 'Cursor Agent' + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull() + }) + + it('emits the chunk agentStatus events before its side-effect batch', () => { + // Cross-channel contract order per chunk: status → titles → bell. + const order: string[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: () => order.push('agentStatus:set'), + onTerminalSideEffects: () => order.push('pty:sideEffect') + }) + syncSinglePty(runtime) + + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07', + 100 + ) + + expect(order).toEqual(['agentStatus:set', 'pty:sideEffect']) + }) + + it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + vi.spyOn( + runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean }, + 'applyTrackedPtyTitle' + ).mockImplementationOnce(() => { + throw new Error('tracker boom') + }) + + const first = '\x1b]0;Codex working\x07' + expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom') + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches).toHaveLength(2) + expect(batches[0].seq).toBe(first.length) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' } + ]) + // The next chunk's batch carries only its own facts (the throw aborted + // the first chunk's agent-tracker pass, so no working state was kept). + expect(batches[1].seq).toBeGreaterThan(batches[0].seq) + expect(batches[1].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' } + ]) + }) + + it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;split ti', 100) + // An 80ms spinner tick lands between the two halves of the real OSC. + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + // Continuation: this BEL terminates the real OSC — it is NOT a bell. + runtime.onPtyData('pty-1', 'tle\x07', 101) + // A later standalone BEL is a real bell and must not be swallowed. + runtime.onPtyData('pty-1', 'ready\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' }, + { kind: 'bell' } + ]) + }) + + it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + const touchSpy = vi.spyOn( + runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void }, + 'touchMobileSessionSnapshotsForPty' + ) + + for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) { + runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`) + } + // Five ticks with the same de-spinnered title: one snapshot fan-out. + expect(touchSpy).toHaveBeenCalledTimes(1) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07') + expect(touchSpy).toHaveBeenCalledTimes(2) + // Raw record titles still track every frame for worktree ps/mobile tabs. + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1')?.lastOscTitle + ).toBe('Cursor ready') + }) + + it('seeds the lazily created tracker from the daemon-snapshot title', async () => { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + // First live chunk creates the tracker cold and kicks off hydration; + // the snapshot seed must land in the already-created tracker. + runtime.onPtyData('pty-1', 'plain output without a title\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + // Without the seed the tracker never saw 'working', so this idle title + // could not produce a completion fact. + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-idle', + title: 'Codex done' + }) + }) + + it('arms the stale-title timer for a seeded working title', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Settle the async daemon-snapshot hydration that seeds the tracker. + await vi.advanceTimersByTimeAsync(0) + runtime.onPtyData('pty-1', 'still no title\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('emits command-code-working facts only after the banner arms the scrape', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Generic status words without the Command Code banner must not arm. + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100) + expect(batches.flatMap((batch) => batch.facts)).toEqual([]) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101) + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches.at(-1)?.facts).toEqual([ + { kind: 'command-code-working', prompt: 'Fix the spinner' } + ]) + }) + + it('emits a command-code-done fact when the idle composer returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100) + runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101) + runtime.onPtyData( + 'pty-1', + '\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...', + 102 + ) + + expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }]) + }) + + it('arms the Command Code scrape from the noted spawn command', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Mirrors the renderer detector's startupCommand fast-arm: no banner + // needed when main saw the launch command at spawn time. + runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust') + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100) + + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'command-code-working', + prompt: 'Fix the spinner' + }) + }) + + it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'renderer scrollback\n', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic hook frame (it no longer + // rides pty:data), so its serializer reports the pre-agent title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07') + // First live chunk kicks off renderer hydration; awaiting the snapshot + // below settles the seed write chain. + runtime.onPtyData('pty-1', 'plain output\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + + const leaves = ( + runtime as unknown as { leaves: Map } + ).leaves + // The seed must not stomp the leaf record (worktree ps status source) + // back to the renderer's stale title. + expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working') + }) + }) + it('returns OSC titles from headless main terminal snapshots', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -6733,6 +7382,16 @@ describe('OrcaRuntimeService', () => { expect(serializeBuffer).not.toHaveBeenCalled() }) + it('advances the absolute output sequence across a daemon stream gap', () => { + const runtime = createRuntime() + runtime.onPtyData('pty-gap', 'before', Date.now()) + + runtime.notePtyDataGap('pty-gap', 4096) + runtime.onPtyData('pty-gap', 'after', Date.now()) + + expect(runtime.getPtyOutputSequence('pty-gap')).toBe('before'.length + 4096 + 'after'.length) + }) + it('emits explicit OSC 9999 agent status from runtime PTY data', () => { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const runtime = new OrcaRuntimeService(store, undefined, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b59afc24a32..be8dc19a9ee 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2,9 +2,9 @@ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { - extractLastOscTitle, detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -13,6 +13,17 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { + createTerminalTitleTracker, + stripBrailleSpinnerGlyphs, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../shared/terminal-side-effect-facts' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload, @@ -688,14 +699,28 @@ 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, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + isNativeWindowsConptyPty, + registerConptyDa1OverrideInstaller, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + getTerminalViewAttributes, + registerTerminalViewAttributesApplier +} from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' -import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { + IFilesystemProvider, + IPtyProvider, + PtyProcessInfo, + PtyTransientFact +} from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -831,6 +856,11 @@ type RuntimeStore = { mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] + // Why: Phase-5 query responder kill switches — read per chunk in + // onPtyData to capture reply ownership at ingestion. + terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] + terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] + terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value @@ -1095,6 +1125,28 @@ export type RuntimeTerminalAgentStatusEvent = { payload: ParsedAgentStatusPayload } +type RuntimePtyTitleTrackerEntry = { + tracker: TerminalTitleTracker + // Why: onPtyData batches the mobile session-tab touch to once per chunk; + // the stale-working-title timer fires between chunks and must touch + // immediately. These flags route the tracker callback to the right mode. + applyingChunk: boolean + // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the + // synthetic path gates mobile snapshot fan-out on a non-decorative title + // change (spinner glyph + status comparison key kept below). + applyingSyntheticFrame: boolean + lastMobileTitleGateKey: string | null + chunkTouchedSessionTabs: boolean + // Why: facts observed while applying a chunk are batched into one + // pty:sideEffect emission per chunk, preserving byte order (titles in + // sequence, then bell). Timer-fired facts emit immediately between chunks. + pendingFacts: TerminalSideEffectFact[] + // Why: Command Code lacks hooks, so its working/done state is scraped from + // TUI output. Null when no side-effect consumer exists (headless serve) — + // the scrape produces facts only. + commandCodeDetector: { observe: (data: string) => boolean } | null +} + // Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and // is then forwarded to the renderer and dropped. Mobile is served by the main // process and has no renderer store, so we retain the latest payload per pane @@ -1121,6 +1173,10 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] + /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh + * emulator so hidden `CSI ? u` answers the real flags instead of ?0u + * (terminal-query-authority.md §kitty). */ + kittyKeyboardFlags?: number } type RuntimePtyController = { @@ -2152,12 +2208,39 @@ export class OrcaRuntimeService { // 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. + // Why a throttle: the blocked-reason check builds and scans two full wait + // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost + // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are + // arbitrary, so running the identical computation over coalesced chunks at + // a bounded cadence (plus a trailing-edge timer so burst-final state is + // always evaluated) preserves semantics while removing it from the hot path. + private waitBlockedCheckStateByPtyId = new Map< + string, + { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + } + >() + private agentStatusOscProcessorsByPtyId = new Map< string, ReturnType >() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working + // timer) replace last-title-per-chunk scanning so main observes the same + // intra-chunk working→idle transitions the renderer does (issue #1083). + // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. + private ptyTitleTrackersByPtyId = new Map() + // Why: the Command Code output detector arms early from the launch command + // when known (banner detection covers user-typed launches), mirroring the + // renderer detector's startupCommand seed. + private terminalSpawnCommandsByPtyId = new Map() // 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. + // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed + // chunks into the title tracker instead of falling back to last-title scans. 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. @@ -2224,6 +2307,14 @@ export class OrcaRuntimeService { > >() + // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe + // stream feeds a remote xterm view (mobile/web/remote desktop) that answers + // queries with view authority, so main must yield while one is attached + // (terminal-query-authority.md). Ref-counted per PTY because multiple + // streams can attach concurrently; mobileSubscribers is consulted too so + // grace-window mobile records keep suppressing. + private remoteTerminalViewSubscriberCounts = new Map() + // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops // xterm.onData/onResize and shows the lock banner; `terminal.send` / @@ -2371,6 +2462,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null + private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null private accountServices: RuntimeAccountServices | null = null @@ -2395,6 +2487,7 @@ export class OrcaRuntimeService { getLocalProvider?: () => IPtyProvider onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. @@ -2431,6 +2524,20 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null + // Why: the ConPTY spawn mark can land after daemon stream data already + // created this PTY's emulator; the mark retrofits the DA1 override here + // (terminal-query-authority.md §ConPTY DA1). + registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) } getLocalProvider(): IPtyProvider | null { @@ -5390,12 +5497,22 @@ export class OrcaRuntimeService { } } + /** Record the spawn launch command so the per-PTY Command Code detector can + * arm from it (renderer startupCommand parity). Best-effort: a chunk that + * beats this call falls back to the detector's banner arming. */ + noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { + const trimmed = typeof command === 'string' ? command.trim() : '' + if (trimmed.length > 0) { + this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) + } + } + /** * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ - onPtyData(ptyId: string, data: string, at: number): number { - const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length + onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd @@ -5410,6 +5527,12 @@ export class OrcaRuntimeService { // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) serveSimStateWatcher.ingestPtyOutput(ptyId, data) + // Why: reply ownership is captured per chunk, here at ingestion — the + // same module state and tick as the hidden-gate drop sites — and rides + // the writeChain link. A mark/setting/subscriber flip before the queued + // emulator write runs must not change who answers (terminal-query- + // authority.md invariant 1). + const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer // MUST run before trackHeadlessTerminalData so the eager-state pattern // (set headlessTerminals + writeChain head = seedPromise) is in place @@ -5418,24 +5541,14 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data, outputSequence) - - // Why: extract OSC title from raw PTY data before tail-buffer processing - // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) - // announce status via OSC 0/1/2 title sequences — this is the same - // detection path the renderer uses for notifications and sidebar badges. - const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data) - // Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini - // per-keystroke updates) once at the observation boundary so lastOscTitle — - // and the mobile session-tab titles/snapshots derived from it — stays - // stable instead of changing every animation frame. Status is detected - // from the raw title (mirroring the renderer's tracker) so working/idle - // transitions are unaffected by normalization. - const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle) - const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null + // Our structure wins: OSC title/agent-status extraction runs through the + // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → + // applyTrackedPtyTitle) in byte order, superseding main's inline + // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are + // preserved via the tracker + detectAgentStatusFromTitle path). + this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies) const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - let shouldTouchPtyBackedSessionTabs = false const ptyTailBefore = pty ? { lines: pty.tailBuffer, @@ -5453,28 +5566,12 @@ export class OrcaRuntimeService { pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) pty.tailPendingAnsi = normalized.pendingAnsi - // Why: the prior chunk's post-append tail is this chunk's pre-append tail, - // so its cached wait scan is exact — reuse it instead of rebuilding and - // rescanning the full tail. Only a tail-derived state is safe to reuse. - const previousWaitState = - pty.tailWaitState?.fromTail === true - ? pty.tailWaitState - : computeTerminalTailWaitState(pty.tailBuffer, pty.tailPartialLine, pty.preview) const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, normalized.text, pty.tailRedrawCursor ) - const nextWaitState = computeTerminalTailWaitState( - nextTail.lines, - nextTail.partialLine, - pty.preview - ) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { - pty.waitBlockedAt = at - } - pty.tailWaitState = nextWaitState ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines pty.tailPartialLine = nextTail.partialLine @@ -5482,40 +5579,7 @@ export class OrcaRuntimeService { pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) - if (oscTitle !== null) { - const prevStatus = pty.lastAgentStatus - const prevTitle = pty.lastOscTitle - const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = oscTitle - pty.lastOscTitleAt = observedAt - pty.lastAgentStatus = agentStatus - this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt) - shouldTouchPtyBackedSessionTabs = - prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus - 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) { - 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) - } - } + this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -5553,7 +5617,10 @@ export class OrcaRuntimeService { leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview leaf.waitBlockedAt = pty.waitBlockedAt - // Why: the leaf mirrors the PTY tail here, so share the cached wait scan. + // Why undefined on this branch: the PTY record's wait scan is throttled + // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; + // copying it here intentionally invalidates the leaf cache and the + // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) @@ -5584,42 +5651,54 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } - - if (oscTitle !== null) { - // Why: keep the latest OSC title on the leaf so worktree.ps can - // recompute status from the live title each call. Without this, - // daemon-hosted terminals (no renderer pushing pane titles) had no - // way to clear a stale 'working' status after the agent exited and - // the shell took over the title — the stuck-spinner bug in #1437. - leaf.lastOscTitle = oscTitle - leaf.lastOscTitleAt = this.nextTitleObservationSequence() - const prevStatus = leaf.lastAgentStatus - // Why: when a new OSC title doesn't classify as an agent state (e.g. - // bare shell title after the agent exits), clear lastAgentStatus so - // it is no longer sticky. Tui-idle waiters that needed the previous - // 'idle' transition were already resolved at the moment of the - // transition below; only fresh waiters registered after the agent - // exits would observe the cleared value, and they correctly fall - // back to title-based detection / polling. - leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle on any transition TO idle (not just working→idle). - // Claude Code may skip "working" entirely on fast tasks, going null→idle, - // and the coordinator's tui-idle waiter would hang forever waiting for a - // working→idle transition that never comes. Permission→idle is excluded: - // it means the agent was blocked on user approval and the user said no, - // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessages(leaf) - } - } } - const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: feed the chunk's OSC titles through the shared per-PTY tracker in + // byte order — the same ordering the renderer transport uses — so + // coalesced working→idle transitions reach tui-idle waiters and + // pending-message delivery instead of being masked by the chunk's last + // title (issue #1083). Uses the OSC 9999-stripped cleanData like the + // renderer, so pure status chunks don't perturb the stale-title probe. + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) + const titleInput = previousTitleScanTail + ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` + : agentStatusChunk.cleanData + const nextTitleScanTail = extractOscTitleScanTail(titleInput) + if (nextTitleScanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + titleTrackerEntry.applyingChunk = true + titleTrackerEntry.chunkTouchedSessionTabs = false + let retainedAgentStatusChanged = false + try { + titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { + titleScanData: titleInput + }) + // Why: the Command Code scrape rides the same per-chunk batch (its facts + // trail the tracker's). cleanData keeps OSC 9999 payloads out of the + // detector's bounded recent-text window; the detector strips remaining + // control sequences itself, exactly like the renderer byte path. + titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) + } finally { + titleTrackerEntry.applyingChunk = false + try { + // Why: per-chunk cross-channel contract order is status → titles → + // bell — the chunk's agentStatus:set events must reach the renderer + // before its pty:sideEffect batch. + retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + } finally { + // Why: flushed in the finally so a throwing tracker callback cannot + // strand this chunk's facts to be emitted under the next chunk's seq. + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + } + } // Why: hook (OSC 9999) transitions often arrive without a title change, so // headless-serve snapshots would never republish and paired remote clients // kept the stale agent state until the next title change (#7970). - if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { + if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5637,6 +5716,80 @@ export class OrcaRuntimeService { return outputSequence } + private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + const appendedLower = appendedText.toLowerCase() + const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) + state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) + // Why the cap keeps the tail: the accumulated text only anchors boundary- + // spanning prompt detection; anything past the tail cap has scrolled out + // of the retained tail the check reads anyway. + state.appended = + state.appended.length + appendedText.length > MAX_TAIL_CHARS + ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) + : `${state.appended}${appendedText}` + const elapsed = at - state.lastAt + if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { + this.runWaitBlockedCheck(ptyId, state, at) + return + } + if (!state.timer) { + // Why trailing edge: the final chunks of a burst must still be + // evaluated or a prompt arriving right after a flood would go + // unstamped until the next output. + state.timer = setTimeout(() => { + state.timer = null + this.runWaitBlockedCheck(ptyId, state, Date.now()) + }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) + } + } + + private runWaitBlockedCheck( + ptyId: string, + state: { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + }, + at: number + ): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + state.appended = '' + return + } + const nextWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + const previousWaitState = state.lastWaitState ?? { + waitText: '', + signal: null, + fromTail: false + } + if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { + pty.waitBlockedAt = at + } + state.lastAt = at + state.lastWaitState = nextWaitState + state.appended = '' + } + + private clearWaitBlockedCheckState(ptyId: string): void { + const state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.waitBlockedCheckStateByPtyId.delete(ptyId) + } + private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) if (!processor) { @@ -5646,19 +5799,439 @@ export class OrcaRuntimeService { return processor(data) } - private extractLastOscTitleForPty(ptyId: string, data: string): string | null { - const previousTail = this.oscTitleScanTailByPtyId.get(ptyId) - if (!previousTail && !data.includes('\x1b')) { + /** Emit the facts batched while applying one chunk/frame as a single + * pty:sideEffect batch, preserving byte order. */ + private flushPendingTerminalSideEffectFacts( + ptyId: string, + entry: RuntimePtyTitleTrackerEntry + ): void { + if (entry.pendingFacts.length === 0) { + return + } + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + + /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through + * the per-PTY tracker — NOT onPtyData, so emulator state, tails, + * transcripts, and stats never see synthetic bytes. Parsed via the + * tracker's stateless synthetic path: the shared chunk bell detector must + * never observe fabricated bytes, or a tick interleaved with a split real + * OSC corrupts its escape state (phantom/swallowed bells). While the + * side-effect kill switch is off the legacy pty:data copy still drives + * renderer parsers; this ingest keeps main's facts and records + * authoritative. */ + ingestSyntheticTitleFrame(ptyId: string, data: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.applyingChunk = true + entry.applyingSyntheticFrame = true + entry.chunkTouchedSessionTabs = false + try { + entry.tracker.applySyntheticTitleFrame(data) + } finally { + entry.applyingChunk = false + entry.applyingSyntheticFrame = false + this.flushPendingTerminalSideEffectFacts(ptyId, entry) + } + if (entry.chunkTouchedSessionTabs) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + + /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail + * thinning): while delegated, the daemon relays bell/133/pr-link/2031 + * facts itself and the delivered bytes may be gapped — feeding them to + * main's transient scanners would mint phantom or duplicate facts. Title + * processing stays main-side either way. */ + setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.tracker.setTransientFactScanningSuppressed(delegated) + if (!delegated && scanSeedAnsi) { + // Prime the freshly reset scanner carry with the emulator's dangling + // incomplete escape at the handoff position — a sequence split across + // the un-background toggle must not mint a phantom bell or lose its + // fact. titleScanData:'' keeps titles out (they were never suppressed). + entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' }) + } + } + + /** A transient fact the daemon detected while it held scan authority — + * emitted through the same fact channel as byte-scanned facts. Arrives + * between chunks, so recordTerminalSideEffectFact emits it immediately. */ + emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { + switch (fact.kind) { + case 'bell': + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + return + case 'command-finished': + this.recordTerminalSideEffectFact(ptyId, { + kind: 'command-finished', + exitCode: fact.exitCode + }) + return + case 'pr-link': + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) + return + case '2031-subscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + + /** The daemon keep-tail dropped this PTY's oldest undelivered output; the + * next delivered chunk is discontinuous. Reset every cross-chunk parse + * carry so a half-open escape from before the gap cannot corrupt what + * follows, and drop the mobile headless mirror — it rebuilds from the + * delivered tail / snapshot seeds instead of parsing a gapped stream. */ + notePtyDataGap(ptyId: string, droppedChars = 0): void { + if (droppedChars > 0) { + // Why: the daemon snapshot's seq counts bytes its monitoring stream + // dropped. Advancing without parsing preserves that absolute domain so + // post-snapshot live chunks can be reconciled instead of duplicated. + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars + this.ptyOutputSequenceById.set(ptyId, outputSequence) + } + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.tailPendingAnsi = '' + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.tailPendingAnsi = '' + } + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + } + + /** Record one derived side-effect fact: batched per chunk while applying + * bytes, emitted immediately for between-chunk facts (stale-title timer). */ + private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + if (!this.onTerminalSideEffects) { + return + } + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.pendingFacts.push(fact) + return + } + this.emitTerminalSideEffectBatch(ptyId, [fact]) + } + + private emitTerminalSideEffectBatch( + ptyId: string, + facts: TerminalSideEffectFact[], + options: { replay?: boolean } = {} + ): void { + if (!this.onTerminalSideEffects || facts.length === 0) { + return + } + const batch: TerminalSideEffectBatch = { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + facts, + ...(options.replay ? { replay: true } : {}), + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + try { + this.onTerminalSideEffects(batch) + } catch (err) { + console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) + } + } + + /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the + * first mounted leaf, fall back to the spawn-time PTY record binding. */ + private resolveTerminalSideEffectAttribution(ptyId: string): { + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null + } { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + return { + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf), + connectionId + } + } + if (pty?.paneKey) { + return { + worktreeId: pty.worktreeId, + ...(pty.tabId ? { tabId: pty.tabId } : {}), + paneKey: pty.paneKey, + connectionId + } + } + return {} + } + + /** Title-only replay batch for renderer (re)attach — the no-attention-replay + * rule: snapshots restore title state, never historical bells/completions. */ + getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { + const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + // Why: the cursor-agent literal drop applies to every title surface; a + // record-fallback snapshot must not replay the bare native title the + // tracker would have refused to emit live. + const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null + const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + if (normalizedTitle === null && !rawTitle) { return null } - const input = `${previousTail ?? ''}${data}` - const scanTail = extractOscTitleScanTail(input) - if (scanTail.length > 0) { - this.oscTitleScanTailByPtyId.set(ptyId, scanTail) - } else { - this.oscTitleScanTailByPtyId.delete(ptyId) + return { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + replay: true, + facts: [ + { + kind: 'title', + normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), + rawTitle: rawTitle ?? normalizedTitle! + } + ], + ...this.resolveTerminalSideEffectAttribution(ptyId) } - return extractLastOscTitle(input) + } + + /** Raw last title from main's tracked PTY/leaf records — the title surface + * the tracker (live bytes + synthetic frames) keeps current. */ + private getTrackedRawTitleForPty(ptyId: string): string | null { + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + if (recordTitle) { + return recordTitle + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + return leaf.lastOscTitle + } + } + return null + } + + /** Why: synthetic agent title frames no longer ride pty:data, so neither + * renderer xterm nor the headless emulator observes them. Mobile-parity + * snapshot titles must prefer main's tracker over snapshot lastTitle, or + * hook-driven spinner/idle titles vanish from mobile tabs. */ + private preferTrackedLastTitle(ptyId: string, snapshot: T): T { + const tracked = this.getTrackedRawTitleForPty(ptyId) + if (!tracked) { + return snapshot + } + return { ...snapshot, lastTitle: tracked } + } + + /** Decorative comparison key: spinner frame glyphs stripped, derived agent + * status kept so a working→idle flip with an otherwise-equal label still + * counts as a change. */ + private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string { + return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( + normalizedTitle + )}` + } + + private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + const existing = this.ptyTitleTrackersByPtyId.get(ptyId) + if (existing) { + return existing + } + // Why: trackers are created lazily on the first observed chunk. After an + // app relaunch the PTY/leaf records can already hold a persisted title; a + // cold tracker would miss the parked working→idle completion and never + // arm the stale-title timer for a persisted 'working' title. + let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + if (initialTitle === null) { + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + initialTitle = leaf.lastOscTitle + break + } + } + } + const tracker = createTerminalTitleTracker( + { + onTitle: (normalizedTitle, rawTitle, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle) + if (!changed) { + return + } + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle) + const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (live) { + live.lastMobileTitleGateKey = gateKey + } + if (live?.applyingChunk) { + // Why: synthetic spinner ticks change only the braille glyph + // ~12.5x/sec; fanning out full mobile session snapshots per frame + // is pure churn. Raw lastOscTitle updates above stay cheap. + if (!(live.applyingSyntheticFrame && decorativeOnly)) { + live.chunkTouchedSessionTabs = true + } + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }, + // Why: agent transitions and bells become pty:sideEffect facts — + // main is the single byte parser for local/SSH PTYs; the renderer + // store handler decides what the facts mean (notification policy). + onAgentBecameWorking: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) + }, + onAgentBecameIdle: (title, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-idle', + title, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + }, + onAgentExited: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }, + // Why: bell/command-finished/pr-link/2031 facts exist only for the + // pty:sideEffect channel. Headless serve has no consumer, so skip the + // per-chunk bell walk and 133/URL/2031 scans entirely. + ...(this.onTerminalSideEffects + ? { + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onCommandFinished: (exitCode: number | null) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see the bytes, so main + // surfaces DECSET 2031 subscribes as facts; the theme reply is + // still sent by the renderer (query authority stays with the view). + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + : {}) + }, + initialTitle !== null ? { initialTitle } : {} + ) + const entry: RuntimePtyTitleTrackerEntry = { + tracker, + applyingChunk: false, + applyingSyntheticFrame: false, + lastMobileTitleGateKey: null, + chunkTouchedSessionTabs: false, + pendingFacts: [], + // Why: command-code facts exist only for the pty:sideEffect channel — + // headless serve skips the per-chunk scrape entirely. The detector + // self-arms on the Command Code banner; the spawn command (when main + // saw one) mirrors the renderer detector's startupCommand fast-arm. + commandCodeDetector: this.onTerminalSideEffects + ? createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + : null + } + this.ptyTitleTrackersByPtyId.set(ptyId, entry) + return entry + } + + /** Apply one observed OSC title (raw form) to the PTY and leaf records. + * Returns true when the PTY record's title or status changed. */ + private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean { + // Why: status is detected from the RAW title (mirrors the renderer tracker), + // so working/idle transitions are unaffected by normalization; the records + // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to + // one stable stored label (#7880) instead of churning `ps`/mobile tabs. + const agentStatus = detectAgentStatusFromTitle(rawTitle) + let ptyRecordChanged = false + const pty = this.ptysById.get(ptyId) + if (pty) { + const prevStatus = pty.lastAgentStatus + const prevTitle = pty.lastOscTitle + const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitle = normalizedTitle + pty.lastOscTitleAt = observedAt + pty.lastAgentStatus = agentStatus + this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) + ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + const shouldDelayMobileSnapshot = + ptyRecordChanged && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) + 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 !== agentStatus) { + 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) { + // Why: report "unchanged" so the per-chunk batch skips the mobile + // snapshot fan-out; the delayed publish fires when the probe settles. + ptyRecordChanged = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = normalizedTitle + leaf.lastOscTitleAt = this.nextTitleObservationSequence() + const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. + leaf.lastAgentStatus = agentStatus + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) + } + } + return ptyRecordChanged + } + + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY + * teardown so it cannot fire into pruned records. */ + private disposePtyTitleTracker(ptyId: string): void { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() + this.ptyTitleTrackersByPtyId.delete(ptyId) } private extractLastOsc7CwdForPty( @@ -5845,6 +6418,53 @@ export class OrcaRuntimeService { return addListenerToMap(this.dataListeners, ptyId, listener) } + /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so + * the daemon background mark (keep-tail stream thinning) can resync — a + * live mobile/web view consumes raw bytes and must never be thinned, even + * while the desktop pane is hidden. */ + onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null + + private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { + try { + this.onRemoteTerminalViewPresenceChanged?.(ptyId) + } catch (err) { + console.error('[runtime] remote view presence listener threw', { ptyId, err }) + } + } + + /** Registered by terminal-RPC subscribe/multiplex streams: while a remote + * view subscriber is attached its xterm answers queries with view + * authority and the model responder must stay silent. Returns an + * idempotent release. */ + registerRemoteTerminalViewSubscriber(ptyId: string): () => void { + this.remoteTerminalViewSubscriberCounts.set( + ptyId, + (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.remoteTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { + if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { + return true + } + return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void @@ -5884,6 +6504,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) @@ -5902,6 +6523,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -5919,6 +6541,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { @@ -5986,17 +6609,22 @@ export class OrcaRuntimeService { return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) state.writeChain = state.writeChain .then(async () => { + // Why: seed writes never set forwardQueryReplies — the main-side + // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) + // Why AFTER the seed write: the snapshot payload cannot carry kitty + // pushes (rehydrateSequences deliberately omits them), but ordering + // behind it keeps the parse deterministic. Unflagged like the seed — + // re-applying flags must answer no one. + if (typeof metadata.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) + } if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } @@ -6037,11 +6665,9 @@ export class OrcaRuntimeService { this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + // Why: hydration writes below never set forwardQueryReplies (main-side + // replay guard) — renderer-buffer snapshots can embed stale queries. + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -6071,9 +6697,14 @@ export class OrcaRuntimeService { if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) } - if (rendered.lastTitle) { - state.emulator.setLastTitle(rendered.lastTitle) - this.applySeededAgentStatus(ptyId, rendered.lastTitle) + // Why: the renderer xterm no longer sees synthetic hook title frames + // (they feed main's tracker only), so its serializer lastTitle can be + // stale here. Prefer main's tracked title; the renderer's is only the + // seed when main has observed none (fresh relaunch, cold tracker). + const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle + if (seedTitle) { + state.emulator.setLastTitle(seedTitle) + this.applySeededAgentStatus(ptyId, seedTitle) } } catch { // Hydration is best-effort. Live writes continue via the same @@ -6094,6 +6725,11 @@ export class OrcaRuntimeService { if (!title) { return } + // Why: a relaunched main starts its per-PTY title tracker cold — without + // this seed it misses the parked working→idle completion and never arms + // the stale-title timer for a persisted 'working' title. Seeding no-ops + // once a live title was observed, so live state always wins. + this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and @@ -6118,11 +6754,28 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { + /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at + * ingestion only — never re-read at reply time. */ + private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { + return shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: this.store?.getSettings(), + hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) + }) + } + + private trackHeadlessTerminalData( + ptyId: string, + data: string, + outputSequence: number, + forwardQueryReplies = false + ): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain .then(async () => { - await state.emulator.write(data) + // Why: the ingestion-time ownership decision is closed over this + // chain link; async scheduling cannot retroactively change it. + await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) .catch(() => { @@ -6131,17 +6784,69 @@ export class OrcaRuntimeService { }) } + /** Shared factory for the per-PTY runtime emulators (seed, hydration, and + * lazy live-byte creation): wires the Phase-5 query-reply sink and the + * ConPTY DA1 override. The daemon emulator never goes through here. */ + private createPtyHeadlessTerminalState( + ptyId: string, + dims: { cols: number; rows: number } + ): RuntimeHeadlessTerminal { + let state: RuntimeHeadlessTerminal | null = null + const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) + const emulator = new HeadlessEmulator({ + cols: dims.cols, + rows: dims.rows, + pathFlavor, + remotePosixFileUriAuthority: + !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + // Why: replies take the provider input path (same entry as pty:write — + // daemon shell-ready gating and the SSH relay write apply unchanged), + // NOT writePtyInput, so renderer interactive-output metering never + // counts responder traffic as user-input echo. + onQueryReply: (reply) => { + // Why the identity check: queued writeChain links can parse after + // disposeHeadlessTerminal, and daemon respawns reuse session ids — a + // stale link's reply must never reach a successor PTY under this id. + if (state !== null && this.headlessTerminals.get(ptyId) === state) { + // Why this write is safe pre-shell-ready: daemon Session.write + // QUEUES (never drops) input while the POSIX shell-ready gate is + // pending and flushes at the ready marker or the 15s + // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query + // reply is delayed at most that bound, not lost. + this.ptyController?.write(ptyId, reply) + } + } + }) + if (isNativeWindowsConptyPty(ptyId)) { + emulator.installConptyPrimaryDeviceAttributesOverride() + } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } + state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + return state + } + + /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via + * markNativeWindowsConptyPty when the spawn mark lands after daemon stream + * data already created this PTY's emulator. Idempotent emulator-side. */ + private ensureNativeWindowsConptyDa1Override(ptyId: string): void { + if (isNativeWindowsConptyPty(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() + } + } + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { const existing = this.headlessTerminals.get(ptyId) if (existing) { return existing } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: size.cols, rows: size.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, size) this.headlessTerminals.set(ptyId, state) return state } @@ -6164,19 +6869,6 @@ export class OrcaRuntimeService { }) } - 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' - }) - } - // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. async clearHeadlessTerminalBuffer(ptyId: string): Promise { @@ -6248,11 +6940,11 @@ export class OrcaRuntimeService { // their existing null fallback paths. } return rendererSnapshot - ? { + ? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), - source: 'renderer' - } + source: 'renderer' as const + }) : null } @@ -6322,9 +7014,10 @@ export class OrcaRuntimeService { source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it - // literally (#7329). + // literally (Bug E / #7329). pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) @@ -6332,36 +7025,35 @@ export class OrcaRuntimeService { return null } await state.writeChain - // Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently - // active, the visible content is the alt-screen snapshot — replaying any - // normal-buffer scrollback before it can duplicate shell prompts and - // flatten SGR attributes when the mobile xterm replays the data. Force - // scrollbackRows=0 in that case. When the buffer is in normal mode the - // caller can request scrollback so the user can scroll up to see prior - // agent output. - const requested = opts.scrollbackRows ?? 0 + // Why: normal history is separated from an active alternate frame, so the + // caller's scrollback policy can be honored without painting it into alt. const isAlternateScreen = state.emulator.isAlternateScreen - const scrollbackRows = isAlternateScreen ? 0 : requested + const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true - ? { + ? this.preferTrackedLastTitle(ptyId, { data, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), lastTitle: snapshot.lastTitle, seq: state.outputSequence, - source: 'headless', + source: 'headless' as const, oscLinks: snapshot.oscLinks, + scrollbackAnsi: snapshot.scrollbackAnsi, ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen - } + alternateScreen: isAlternateScreen, + // Why NOT folded into data: the renderer writes its post-replay + // reset after data, and any ESC after a dangling partial aborts it. + // The restorer writes this last (Bug E fix). + pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi + }) : null } @@ -6372,6 +7064,10 @@ export class OrcaRuntimeService { return } this.headlessTerminals.delete(ptyId) + // Why: queued chain links still parse below before the emulator disposes; + // sever the reply sink now so they cannot write to a respawned PTY that + // reused this id (belt to the sink's state-identity check). + state.emulator.disableQueryReplyForwarding() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } @@ -7172,6 +7868,7 @@ export class OrcaRuntimeService { ? { cols: subscriber.previousCols, rows: subscriber.previousRows } : null inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) } else { @@ -7256,13 +7953,17 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) + this.remoteTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -7985,6 +8686,20 @@ export class OrcaRuntimeService { ptyId: string, clientId: string, viewport?: { cols: number; rows: number } + ): Promise { + try { + return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) + } finally { + // Every subscribe path mutates mobileSubscribers — resync the daemon + // background mark once, whatever branch returned. + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + private async handleMobileSubscribeInternal( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } ): Promise { const mode = this.getMobileDisplayMode(ptyId) @@ -8157,6 +8872,7 @@ export class OrcaRuntimeService { const wasResizedToPhone = subscriber.wasResizedToPhone inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { // Why: if the leaving client was the only one with a non-null restore @@ -18861,9 +19577,12 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -23355,6 +24074,14 @@ export class OrcaRuntimeService { } } +const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 +// Why: chunks that can complete an actionable prompt bypass the throttle so +// blocked stamps stay per-chunk-immediate; the pattern heads mirror +// findTerminalWaitBlockedSignal. Scanned over the new chunk plus a short +// carry only — never the accumulated window. +const WAIT_BLOCKED_KEYWORD_PATTERN = + /press enter|press t to trust|do you trust|trust this|trusted workspace|update available|choose working directory|codex just got an upgrade|hooks need review/ +const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 const MAX_TAIL_PARTIAL_CHARS = 4000 @@ -23918,6 +24645,30 @@ function trimTerminalLineRight(line: string): string { return end === line.length ? line : line.slice(0, end) } +// Why a window: the unwindowed implementation below materializes a row object +// per retained tail line and finalize re-allocates + regex-trims every row — +// O(tail) per chunk (~0.9ms at the 2,000-line cap), measured at ~93% of the +// main-process event loop under an agent-TUI flood (findings log 2026-07-03). +// A redraw can only touch rows the cursor can reach, so run the algorithm on +// a suffix window sized by the chunk's maximum upward cursor excursion and +// share the untouched prefix by reference. Equality with the unwindowed +// implementation is fuzz-verified in +// retained-tail-redraw-window.equivalence.test.ts. +const REDRAW_WINDOW_SAFETY_ROWS = 8 + +function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g + let match: RegExpExecArray | null + while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, @@ -23930,6 +24681,79 @@ function appendNormalizedToMultilineTailBuffer( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number +} { + const windowRows = + maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + if (windowRows >= previousLines.length) { + return appendNormalizedToMultilineTailBufferUnwindowed( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + } + const prefixLength = previousLines.length - windowRows + const suffix = previousLines.slice(prefixLength) + const windowed = appendNormalizedToMultilineTailBufferUnwindowed( + suffix, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + let lines = previousLines.slice(0, prefixLength) + // Why: the unwindowed finalize trims trailing spaces/tabs on every row; the + // shared prefix must match without paying a regex per untouched row. + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + lines[index] = line.replace(/[ \t]+$/g, '') + } + } + for (const line of windowed.lines) { + lines.push(line) + } + let truncated = windowed.truncated + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + let totalChars = windowed.partialLine.length + for (const line of lines) { + totalChars += line.length + } + let dropCount = 0 + while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= lines[dropCount]!.length + dropCount += 1 + } + if (dropCount > 0) { + lines = lines.slice(dropCount) + truncated = true + } + return { + lines, + partialLine: windowed.partialLine, + redrawCursor: windowed.redrawCursor, + truncated, + newCompleteLines: windowed.newCompleteLines + } +} + +export function appendNormalizedToMultilineTailBufferUnwindowed( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number } { const rows: RetainedTerminalRow[] = [ ...previousLines.map((line) => ({ text: line, completed: true })), diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts new file mode 100644 index 00000000000..32685f93b53 --- /dev/null +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + appendNormalizedToTailBuffer, + appendNormalizedToMultilineTailBufferUnwindowed +} from './orca-runtime' + +// Differential guard for the windowed redraw tail path: the public +// appendNormalizedToTailBuffer routes vertical-control chunks through a +// suffix-windowed wrapper (findings log 2026-07-03 — the unwindowed path was +// O(tail) per chunk and dominated main's event loop under agent-TUI floods). +// This fuzz asserts the windowed result is byte-identical to the reference +// implementation across randomized tails and redraw chunks. + +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function randomTail(rng: () => number, maxLines: number): string[] { + const count = Math.floor(rng() * maxLines) + return Array.from({ length: count }, (_, i) => { + const base = `line ${i} ${'x'.repeat(Math.floor(rng() * 40))}` + // Trailing whitespace included deliberately: the reference implementation + // trims every row on each call, so the windowed prefix must match. + return rng() < 0.3 ? `${base} ` : base + }) +} + +function randomRedrawChunk(rng: () => number): string { + const parts: string[] = [] + const ops = 1 + Math.floor(rng() * 12) + for (let i = 0; i < ops; i++) { + const roll = rng() + if (roll < 0.2) { + parts.push(`\x1b[${1 + Math.floor(rng() * 12)}A`) + } else if (roll < 0.3) { + parts.push(`\x1b[${Math.floor(rng() * 3)}J`) + } else if (roll < 0.4) { + parts.push(`\x1b[${Math.floor(rng() * 3)}K`) + } else if (roll < 0.5) { + parts.push('\r') + } else if (roll < 0.6) { + parts.push(`\x1b[${1 + Math.floor(rng() * 30)}G`) + } else if (roll < 0.7) { + parts.push('\n') + } else if (roll < 0.75) { + parts.push('') + } else { + parts.push(`text${Math.floor(rng() * 100)} ${'y'.repeat(Math.floor(rng() * 20))}`) + } + } + return parts.join('') +} + +describe('windowed redraw tail equivalence', () => { + it('matches the unwindowed reference across 500 randomized cases', () => { + const rng = mulberry32(42) + for (let round = 0; round < 500; round++) { + const tail = randomTail(rng, round % 5 === 0 ? 2100 : 300) + const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : '' + const redrawCursor = + rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null + // Why the guaranteed cursor-up: the public function routes to the + // multiline (windowed) path only for vertical-control chunks; chunks + // without one take the single-line fast path, which is out of scope. + const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}` + + const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) + // Reference path over the full tail. + const expected = appendNormalizedToMultilineTailBufferUnwindowed( + tail, + partial.slice(-4000), + chunk, + partial.length > 4000, + redrawCursor + ) + + expect(actual.lines, `round ${round} lines`).toEqual(expected.lines) + expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine) + expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) + expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) + expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ff553f812a7..73c77701c97 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -36,6 +36,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 // Why: output batches become binary stream payloads; byte size is the transport cost. const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +// Why: remote clients can apply output pressure without pausing runtime PTY ingestion. +const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024 +const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 @@ -60,6 +63,7 @@ type SnapshotFrameOptions = { type SerializedSnapshot = { data: string + scrollbackAnsi?: string cols: number rows: number seq?: number @@ -82,7 +86,13 @@ type TerminalMultiplexStream = { ptyId: string client: TerminalViewportClient | undefined isMobile: boolean + ackOutput: boolean + ackInFlightBytes: number buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean pendingOutput: TerminalOutputChunk[] pendingOutputBytes: number pendingOutputOverflowed: boolean @@ -362,6 +372,26 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + function trimPendingOutputToBudget( pendingOutput: TerminalOutputChunk[], pendingOutputBytes: number @@ -388,6 +418,43 @@ function measureTerminalStreamByteLength( return measureClipboardTextByteLength(data, options) } +function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + function terminalStreamByteLength(data: string): number { return measureTerminalStreamByteLength(data).byteLength } @@ -436,13 +503,12 @@ async function serializeBudgetedRequestedSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - REQUESTED_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < requestedRows || overByteBudget } @@ -491,7 +557,14 @@ async function serializeBudgetedMobileSnapshot( ): Promise { if (!isMobile) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized ? { ...serialized, scrollbackRows: 0, truncatedByByteBudget: false } : null + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null } const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] for (const rows of candidates) { @@ -499,13 +572,12 @@ async function serializeBudgetedMobileSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget } @@ -749,7 +821,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: TerminalViewport.optional() + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional() + }) + .optional() +}) + +const TerminalMultiplexAckFrame = z.object({ + bytes: z.number().int().nonnegative() }) const TerminalMultiplexSnapshotRequestFrame = z.object({ @@ -1224,6 +1305,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let cursor = 0 const streams = new Map() + let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((resolve) => { resolveMultiplex = resolve @@ -1269,6 +1351,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) ) } + const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES && + ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES + ) + } + const sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + ackTotalInFlightBytes += chunk.bytes.byteLength + } + } + const queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + sendAckGatedOutput(stream, chunk) + } + const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + closed || + streams.get(stream.streamId) !== stream || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(stream.ptyId) + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + // Why: dropped ACK-pending output means live frames are no longer a + // complete replay. Send a fresh model snapshot before resuming output. + // Why: truncated marks an unusable snapshot, and clients discard + // those. The recovery snapshot must be applied to cover dropped + // output, so it is only truncated when serialization failed. + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized?.seq, + source: serialized?.source, + truncated: !serialized, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + if (serialized && typeof serialized.seq === 'number') { + // Why: retained chunks queued before the snapshot serialized are + // already contained in it; replaying them would duplicate output. + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + flushAckPendingOutput(stream) + } + } + } + const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => { + if (stream.ackPendingOutputOverflowed) { + void sendAckRecoverySnapshot(stream) + return + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!) + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + } + const flushAllAckPendingOutput = (): void => { + for (const stream of streams.values()) { + flushAckPendingOutput(stream) + } + } + const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackInFlightBytes -= acknowledged + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) + flushAllAckPendingOutput() + } const detachStream = (streamId: number, emitEnd: boolean): void => { const stream = streams.get(streamId) if (!stream) { @@ -1276,12 +1488,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.flush() stream.outputBatcher.dispose() + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false stream.unsubscribeData() stream.unsubscribeResize() stream.unsubscribeFit() stream.unsubscribeDriver() stream.unregisterBinaryHandler() streams.delete(streamId) + flushAllAckPendingOutput() // Why: release the runtime exit-waiter for this slot (see the field's // note). The .catch below no-ops because the stream is already deleted. stream.exitWaiterAbort.abort() @@ -1314,6 +1533,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ detachStream(stream.streamId, false) return } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const parsed = TerminalMultiplexAckFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + if (parsed.success) { + acknowledgeOutput(stream, parsed.data.bytes) + } + return + } if (frame.opcode === TerminalStreamOpcode.Input) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -1482,6 +1710,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ emit({ type: 'end', streamId: request.streamId }) return } + if (closed) { + return + } + // Why: a competing subscribe for the same streamId can fully register + // while this one awaited the PTY id above. Overwriting it in + // `streams` would orphan its data/view-subscriber registrations — a + // leaked view subscriber permanently silences the model query + // responder (terminal-query-authority.md). Detach it so every + // registration stays release-balanced. + detachStream(request.streamId, false) const ptyId = leaf.ptyId const stream: TerminalMultiplexStream = { @@ -1490,7 +1728,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ptyId, client: request.client, isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackInFlightBytes: 0, buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, pendingOutput: [], pendingOutputBytes: 0, pendingOutputOverflowed: false, @@ -1506,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + queueOrSendOutput(stream, chunk) } }), unsubscribeData: () => {}, @@ -1522,7 +1766,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) try { - stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed || streams.get(request.streamId) !== stream) { return } @@ -1532,6 +1776,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.push(data, meta) }) + // Why: a multiplexed stream feeds a remote xterm view that answers + // terminal queries with view authority; the main model responder + // yields while it is attached (terminal-query-authority.md). + // Wrapped into unsubscribeData so every detach path releases it. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) @@ -1718,6 +1971,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } }) } catch (error) { + // Why the ownership check: a newer subscribe may own this streamId + // now (it detached and released this stream on arrival). Detaching + // or erroring the slot here would tear down the successor's live + // registrations instead of this stream's. + if (streams.get(request.streamId) !== stream) { + return + } detachStream(request.streamId, false) sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) emit({ type: 'end', streamId: request.streamId }) @@ -1819,9 +2079,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) - const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { outputBatcher.push(data) }) + // Why: this legacy JSON stream can feed a live xterm view too + // (older web/desktop subscribers), so it conservatively registers + // as a remote view subscriber. For read-only watchers the cost is + // a withheld model reply — the pre-Phase-5 status quo — which is + // strictly safer than a double reply under a view consumer. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + const unsubscribeData = (): void => { + releaseViewSubscriber() + unsubscribeStreamData() + } const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { outputBatcher.flush() emit({ @@ -1861,7 +2131,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 - const pendingOutput: TerminalOutputChunk[] = [] + let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false let unsubscribeData = (): void => {} @@ -1975,7 +2245,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } @@ -1996,6 +2266,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } outputBatcher?.push(data, meta) }) + // Why: binary subscribe streams feed remote xterm views (mobile and + // binary-capable desktop clients) that answer queries with view + // authority; the main model responder yields while attached. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) @@ -2066,6 +2344,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: baseline for resize re-stream gating; the client already // rewrapped to these cols via the initial snapshot replay. lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh + // model snapshot can cover the dropped middle without replay gaps. + while (pendingOutputOverflowed && recoveryAttempts < 2) { + pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer-source fallback) covered + // chunks cannot be trimmed exactly, and the renderer view may lag + // the queued chunks under backpressure. Keep the bounded replay + // instead of applying an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: shipped mobile clients drop a second scrollback snapshot for + // an initialized handle but apply a resized snapshot inline by + // re-initializing xterm with fresh scrollback. Omit seq on the wire + // so the client's layout-seq staleness filter is not polluted with + // output-byte sequences. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) + pendingOutput = trimmed.chunks + pendingOutputBytes = trimmed.bytes + } buffering = false const bufferedOutput = pendingOutput.splice(0) if (!initialOutputOverflowed) { diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index abc9ab20372..9cdc88a021b 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 24367102745..88125c840b3 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -53,6 +53,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index edb5afc030a..62dcfddc5ab 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -18,6 +18,14 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: every multiplex stream registers as a remote view subscriber for + // Phase-5 query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, + // Why: the multiplex subscribe path resolves handles via + // resolveLiveLeafForHandle (#7718). Default to a live pty so tests that + // only stub the legacy resolveLeafForHandle still bind; tests that need a + // null/stale leaf override this explicitly. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), ...overrides } as OrcaRuntimeService } @@ -498,6 +506,708 @@ describe('terminal multiplex RPC', () => { } }) + it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-gated', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 16, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(700 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + expect(initialOutputFrames.length).toBeGreaterThan(0) + const initialOutput = initialOutputFrames + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(initialOutput.length).toBeLessThan(output.length) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 16, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 16, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + const flushedOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated') + await dispatchPromise + }) + + it('releases shared ACK budget to other stalled multiplex streams', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListeners = new Map< + string, + (data: string, meta?: { seq?: number; rawLength?: number }) => void + >() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + resolveLiveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({ + data: `snapshot-${ptyId}`, + cols: 120, + rows: 40 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + ptyId: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListeners.set(ptyId, listener) + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-shared-budget', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + + const streamIds = [21, 22, 23, 24, 25, 26] + for (const streamId of streamIds) { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId - 20}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + } + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .filter((result) => result?.type === 'subscribed') + ).toHaveLength(streamIds.length) + ) + await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length)) + binaryFrames.splice(0) + + const fillerOutput = 'f'.repeat(480 * 1024) + for (let index = 1; index <= 4; index += 1) { + dataListeners.get(`pty-${index}`)?.(fillerOutput, { + seq: fillerOutput.length, + rawLength: fillerOutput.length + }) + } + const stalledOutput = 's'.repeat(700 * 1024) + dataListeners.get('pty-5')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + dataListeners.get('pty-6')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytesByStream = new Map() + for (const frame of initialOutputFrames) { + if (!frame) { + continue + } + initialBytesByStream.set( + frame.streamId, + (initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024) + expect(initialBytesByStream.get(21)).toBe(480 * 1024) + expect(initialBytesByStream.get(22)).toBe(480 * 1024) + expect(initialBytesByStream.get(23)).toBe(480 * 1024) + expect(initialBytesByStream.get(24)).toBe(480 * 1024) + expect(initialBytesByStream.get(25)).toBeGreaterThan(0) + expect(initialBytesByStream.get(26) ?? 0).toBe(0) + + handlers.get(26)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 26, + seq: 200, + payload: encodeTerminalStreamText('remote-still-interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }) + ) + + const frameCountBeforeAck = binaryFrames.length + handlers.get(21)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + seq: 201, + payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const framesAfterAck = binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + const snapshotStartIndex = framesAfterAck.findIndex((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const outputFramesAfterAck = framesAfterAck.filter( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + const bytesAfterAckByStream = new Map() + for (const frame of outputFramesAfterAck) { + if (!frame) { + continue + } + bytesAfterAckByStream.set( + frame.streamId, + (bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + expect(snapshotStartIndex).toBeGreaterThanOrEqual(0) + expect( + framesAfterAck + .filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output) + .every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex) + ).toBe(true) + expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0) + expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0) + expect( + outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + ).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget') + await dispatchPromise + }) + + it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-overflow', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 17, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(3 * 1024 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 17, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + binaryFrames.splice(0) + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 17, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + const recoveryStartIndex = drainFrames.findIndex((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const firstOutputAfterAckIndex = drainFrames.findIndex( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + expect(recoveryStartIndex).toBeGreaterThanOrEqual(0) + // Why: clients discard truncated snapshots; a usable recovery snapshot + // must not be marked truncated or the dropped output gap is permanent. + expect( + decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload) + ?.truncated + ).toBe(false) + expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex) + expect( + drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + + const outputBytesAfterRecovery = drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow') + await dispatchPromise + }) + + it('trims recovery-covered ACK pending output instead of replaying it', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const floodedChars = 3 * 1024 * 1024 + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + // Why: the recovery snapshot seq covers the entire flood, so every + // retained pending chunk is already contained in the snapshot. + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-trim', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 31, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(floodedChars) + dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars }) + const initialBytes = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + binaryFrames.splice(0) + handlers.get(31)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 31, + seq: 2, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + + const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + expect( + framesAfterRecovery + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + // Why: every retained chunk is covered by the recovery snapshot seq; + // replaying any of them would duplicate snapshot content. + expect( + framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + ).toEqual([]) + + binaryFrames.splice(0) + const fresh = 'fresh-after-recovery\r\n' + dataListenerRef.current?.(fresh, { + seq: floodedChars + fresh.length, + rawLength: fresh.length + }) + await vi.waitFor(() => { + const freshOutput = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(freshOutput).toBe(fresh) + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -1472,6 +2182,238 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + // Why: a leaked registration permanently suppresses the model query + // responder (terminal-query-authority.md) — the count must return to 0. + let viewSubscriberCount = 0 + let leafResolved = false + let resolveFirstWait: (ptyId: string) => void = () => {} + // Why: the multiplex subscribe path resolves via resolveLiveLeafForHandle + // (#7718); null makes subscribe A block in waitForLeafPtyId until B resolves. + const resolveLeaf = (): { ptyId: string | null } => + leafResolved ? { ptyId: 'pty-1' } : { ptyId: null } + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn(resolveLeaf), + resolveLiveLeafForHandle: vi.fn(resolveLeaf), + waitForLeafPtyId: vi.fn( + () => + new Promise((resolve) => { + resolveFirstWait = resolve + }) + ), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-overwrite', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId) + // then resolves the leaf directly and fully registers. + sendSubscribe() + await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) + leafResolved = true + sendSubscribe() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + + // A resumes and takes the slot; B's registration must be released, not + // orphaned by the overwrite. + resolveFirstWait('pty-1') + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 2 + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 7, + seq: 2, + payload: new Uint8Array() + }) + )! + ) + expect(viewSubscriberCount).toBe(0) + + cleanups.get('terminal-multiplex:conn-overwrite')?.() + await dispatchPromise + }) + + it('keeps an evicted subscribe error from detaching the successor stream', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let viewSubscriberCount = 0 + const mobileSubscribeWaiters: { + resolve: () => void + reject: (error: Error) => void + }[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + // Why: the multiplex subscribe path resolves the leaf via + // resolveLiveLeafForHandle (#7718), so it must return a live pty here. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve, reject) => { + mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject }) + }) + ), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-evicted-error', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // A registers, then blocks in handleMobileSubscribe. B (same streamId) + // evicts A on arrival and completes its own registration. + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1)) + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2)) + mobileSubscribeWaiters[1]!.resolve() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + expect(viewSubscriberCount).toBe(1) + + // A's pending await now rejects. The evicted stream must not detach the + // successor that owns the slot. + mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed')) + await Promise.resolve() + await Promise.resolve() + expect(viewSubscriberCount).toBe(1) + + cleanups.get('terminal-multiplex:conn-evicted-error')?.() + await dispatchPromise + expect(viewSubscriberCount).toBe(0) + }) + it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => { // Why: after a reconnect a client can resubscribe with a handle whose // pane now hosts a different PTY. Binding the stream anyway would mirror diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index df81e05e057..d10456c8fa6 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f4a70cbeebd..ba921c6b14e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -14,6 +14,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } @@ -346,13 +349,6 @@ 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) @@ -367,21 +363,35 @@ describe('terminal subscribe buffering', () => { await dispatchPromise }) - it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + it('recovers binary output overflow queued while the initial snapshot is serializing', async () => { vi.useFakeTimers() try { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] const cleanups = new Map void>() - const dataListenerRef: { current?: (data: string) => void } = {} - const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => 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) => { - snapshotResolves.push(resolve) + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -427,34 +437,54 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const shiftSpy = vi.spyOn(Array.prototype, 'shift') + let seq = 0 for (let index = 0; index < 400; index += 1) { - dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) } const shiftCallCount = shiftSpy.mock.calls.length shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) - snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) + snapshotResolvers[1]?.({ + data: 'recovered after overflow\r\n', + cols: 120, + rows: 40, + seq, + source: 'headless' + }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) await vi.runOnlyPendingTimersAsync() - const output = binaryFrames + const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload)) + // Why one snapshot: overflow during the initial serialize is recovered + // INLINE (drop pending, re-read, re-serialize) before anything is sent, + // so the client's first scrollback snapshot is already current. The + // 'resized'/pending-output-overflow follow-up path remains only for + // overflow that begins after the initial snapshot went out. + expect(decodedStarts).toEqual([expect.objectContaining({ kind: 'scrollback', seq })]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((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).not.toContain('399') + expect(snapshotText).toContain('recovered after overflow') expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') @@ -612,4 +642,129 @@ describe('terminal subscribe buffering', () => { runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise }) + + it('applies inline overflow recovery when the snapshot has no output seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => 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 + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-no-seq', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + let seq = 0 + for (let index = 0; index < 400; index += 1) { + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + // Why applying is safe without a seq here: inline recovery serialized + // AFTER dropping the overflowed pending queue, so the snapshot covers + // those chunks by construction — no seq-based trimming is needed. + snapshotResolvers[1]?.({ + data: 'renderer fallback snapshot\r\n', + cols: 120, + rows: 40, + source: 'renderer' + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const decodedFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the + // layout seq on the wire; the recovered data still ships as the first + // and only scrollback snapshot. + expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + expect.objectContaining({ kind: 'scrollback', seq: 1 }) + ]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + expect(snapshotText).toContain('renderer fallback snapshot') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + // Why empty: the overflowed pending queue was dropped before the + // covering snapshot was serialized; nothing needs replay. + expect(output).toBe('') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 5f50c32845e..2d7579cce48 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' @@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const writes: { terminal: string; text: string }[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'multiplex-background-pty' }) + .mockResolvedValueOnce({ id: 'multiplex-active-pty' }) + runtime.setPtyController({ + spawn, + write: (ptyId, data) => { + writes.push({ terminal: ptyId, text: data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const phoneOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(phoneOffer.available).toBe(true) + if (!phoneOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + const pairing = parsePairingCode(phoneOffer.pairingUrl) + expect(pairing).toBeTruthy() + if (!pairing) { + throw new Error('Pairing URL did not parse') + } + + const metadata = readRuntimeMetadata(userDataPath) + const laptopEndpoint = metadata!.transports[0]!.endpoint + const laptopAuthToken = metadata!.authToken + const worktree = 'id:repo-1::/tmp/worktree-a' + const backgroundLeafId = '11111111-1111-4111-8111-111111111111' + const activeLeafId = '22222222-2222-4222-8222-222222222222' + const backgroundCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_background', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'background', + tabId: 'multiplex-background-tab', + leafId: backgroundLeafId + } + }) + const activeCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_active', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'active', + tabId: 'multiplex-active-tab', + leafId: activeLeafId, + activate: true + } + }) + const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } }) + .terminal + const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } }) + .terminal + + const responses: Record[] = [] + const binaryFrames: Uint8Array[] = [] + const onError = vi.fn() + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.multiplex', + {}, + 15_000, + { + onResponse: (response) => responses.push(response as Record), + onBinary: (bytes) => binaryFrames.push(bytes), + onError + } + ) + + try { + await vi.waitFor(() => + expect( + responses.some( + (response) => (response.result as { type?: string } | undefined)?.type === 'ready' + ) + ).toBe(true) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 1, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 21, + terminal: backgroundTerminal.handle, + client: { id: 'desktop-background', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 2, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 22, + terminal: activeTerminal.handle, + client: { id: 'desktop-active', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + await vi.waitFor(() => { + const subscribedStreamIds = responses + .map((response) => response.result as { type?: string; streamId?: number } | undefined) + .filter((result) => result?.type === 'subscribed') + .map((result) => result?.streamId) + expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22])) + }) + binaryFrames.splice(0) + + const backgroundOutput = 'B'.repeat(700 * 1024) + runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1) + await vi.waitFor(() => { + const backgroundFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + const backgroundBytes = backgroundFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(backgroundBytes).toBeGreaterThan(0) + expect(backgroundBytes).toBeLessThan(backgroundOutput.length) + }) + + const frameCountBeforeActive = binaryFrames.length + runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2) + await vi.waitFor(() => { + const activeOutput = binaryFrames + .slice(frameCountBeforeActive) + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY') + }) + + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 3, + opcode: TerminalStreamOpcode.Input, + streamId: 22, + payload: encodeTerminalStreamText('still interactive\r') + }) + ) + await vi.waitFor(() => + expect(writes).toContainEqual({ + terminal: 'multiplex-active-pty', + text: 'still interactive\r' + }) + ) + + const backgroundBytesBeforeAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 4, + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck }) + }) + ) + await vi.waitFor(() => { + const backgroundBytesAfterAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck) + }) + expect(onError).not.toHaveBeenCalled() + } finally { + subscription.close() + await server.stop() + } + }) + it('serves worktree.ps from the runtime summary builder', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never) diff --git a/src/main/runtime/terminal-model-query-authority.test.ts b/src/main/runtime/terminal-model-query-authority.test.ts new file mode 100644 index 00000000000..3217a98022e --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetTerminalModelQueryAuthorityForTest, + clearNativeWindowsConptyPty, + isNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + isTerminalModelQueryAuthorityEnabled, + markNativeWindowsConptyPty, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest +} from '../ipc/pty-hidden-delivery-gate' + +const ALL_ON = { + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true +} + +afterEach(() => { + _resetTerminalModelQueryAuthorityForTest() + _resetHiddenRendererPtyDeliveryGateForTest() +}) + +describe('isTerminalModelQueryAuthorityEnabled', () => { + it('defaults on, including for absent settings', () => { + expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true) + }) + + it('is an independent off switch for the responder alone', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false }) + ).toBe(false) + }) + + it('requires both Phase-4 gate switches — no marks exist without them', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false }) + ).toBe(false) + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false }) + ).toBe(false) + }) +}) + +describe('shouldModelAnswerHiddenPtyQueries', () => { + const answer = (ptyId: string, overrides: Record = {}): boolean => + shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: { ...ALL_ON, ...overrides }, + hasRemoteViewSubscriber: false + }) + + it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => { + expect(answer('pty-1')).toBe(false) + markHiddenRendererPty('pty-1') + expect(answer('pty-1')).toBe(true) + expect(answer('pty-other')).toBe(false) + }) + + it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => { + markHiddenRendererPty('pty-1') + setRendererPtyDeliveryInterest('pty-1', true) + expect(answer('pty-1')).toBe(false) + setRendererPtyDeliveryInterest('pty-1', false) + expect(answer('pty-1')).toBe(true) + }) + + it('yields while a remote view subscriber is attached', () => { + markHiddenRendererPty('pty-1') + expect( + shouldModelAnswerHiddenPtyQueries({ + ptyId: 'pty-1', + settings: ALL_ON, + hasRemoteViewSubscriber: true + }) + ).toBe(false) + }) + + it('stays silent under any kill switch', () => { + markHiddenRendererPty('pty-1') + expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false) + expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false) + expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false) + }) +}) + +describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => { + const base = { + connectionId: null, + cwd: 'C:\\repo', + shellOverride: undefined, + platform: 'win32' as NodeJS.Platform + } + + it('matches local native Windows spawns', () => { + expect(isNativeWindowsLocalPtySpawn(base)).toBe(true) + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' }) + ).toBe(true) + }) + + it('rejects non-Windows hosts', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false) + }) + + it('rejects SSH-backed spawns', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false) + }) + + it('rejects WSL cwds and WSL shell overrides', () => { + expect( + isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false) + }) +}) + +describe('native-Windows ConPTY spawn record', () => { + it('marks, reads, and clears per PTY', () => { + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + markNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(true) + expect(isNativeWindowsConptyPty('pty-2')).toBe(false) + clearNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts new file mode 100644 index 00000000000..e1e0f9c28ef --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -0,0 +1,111 @@ +/** + * Phase 5 of the terminal model/view architecture: main-side terminal query + * authority (docs/reference/terminal-query-authority.md). + * + * The delivery decision is the reply decision: main answers a query iff the + * hidden-delivery gate dropped the chunk that carried it. This module owns + * the responder kill-switch predicate and the main-side mirror of the + * renderer's native-Windows-ConPTY determination, recorded per PTY at spawn + * so the runtime emulator can register the DA1 override before byte zero. + */ +import type { GlobalSettings } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' + +export type TerminalModelQueryAuthoritySettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority' +> + +/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops + * exist without them) plus the Phase-5-specific independent off switch. */ +export function isTerminalModelQueryAuthorityEnabled( + settings: TerminalModelQueryAuthoritySettings | null | undefined +): boolean { + return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false +} + +/** Per-chunk reply-ownership predicate, evaluated once at ingestion in + * OrcaRuntimeService.onPtyData — the same module state and tick as the + * hidden-gate drop sites, so "chunk dropped" and "main answers" cannot + * diverge for live chunks. Remote view subscribers (mobile/web/remote + * desktop xterms on the multiplexed stream) keep view authority, so main + * yields while one is attached. */ +export function shouldModelAnswerHiddenPtyQueries(opts: { + ptyId: string + settings: TerminalModelQueryAuthoritySettings | null | undefined + hasRemoteViewSubscriber: boolean +}): boolean { + return ( + isTerminalModelQueryAuthorityEnabled(opts.settings) && + !opts.hasRemoteViewSubscriber && + shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings) + ) +} + +/** Main-side mirror of the renderer's isLocalNativeWindowsPty + * (windows-pty-compatibility.ts), computed from spawn-time facts: local or + * daemon provider (no SSH connection), win32 host, and not a WSL shell. */ +export function isNativeWindowsLocalPtySpawn(opts: { + connectionId: string | null | undefined + cwd: string | null | undefined + shellOverride: string | null | undefined + platform?: NodeJS.Platform +}): boolean { + if ((opts.platform ?? process.platform) !== 'win32') { + return false + } + if (opts.connectionId) { + return false + } + if (isWslUncPath(opts.cwd ?? '')) { + return false + } + if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) { + return false + } + return true +} + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records +// the determination at spawn, the runtime consults it at emulator creation. +// Daemon-adopted PTYs from a previous app run carry no mark — acceptable: +// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life. +const nativeWindowsConptyPtys = new Set() + +// Why installers: the mark lands after the awaited spawn response, but daemon +// stream data (warm-reattach flush) can lazy-create the runtime emulator +// first. The runtime registers an installer so marking retrofits the DA1 +// override onto an existing emulator; installation is idempotent emulator-side. +type ConptyDa1OverrideInstaller = (ptyId: string) => void +const conptyDa1OverrideInstallers = new Set() + +export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void { + conptyDa1OverrideInstallers.add(installer) +} + +export function markNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.add(id) + for (const installer of conptyDa1OverrideInstallers) { + installer(id) + } +} + +export function isNativeWindowsConptyPty(id: string): boolean { + return nativeWindowsConptyPtys.has(id) +} + +/** Wired into clearProviderPtyState so every PTY teardown path releases the + * spawn record. */ +export function clearNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.delete(id) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalModelQueryAuthorityForTest(): void { + nativeWindowsConptyPtys.clear() + conptyDa1OverrideInstallers.clear() +} diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts new file mode 100644 index 00000000000..b6988e06731 --- /dev/null +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -0,0 +1,827 @@ +/** + * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * reply parity through the runtime emulator, the per-chunk ownership matrix, + * the main-side replay guard, and the ingestion-time capture race. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest, + unmarkHiddenRendererPty +} from '../ipc/pty-hidden-delivery-gate' +import { + _resetTerminalModelQueryAuthorityForTest, + markNativeWindowsConptyPty +} from './terminal-model-query-authority' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' +import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' + +const settingsState = { + terminalMainSideEffectAuthority: true as boolean, + terminalHiddenDeliveryGate: true as boolean, + terminalModelQueryAuthority: true as boolean +} + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority, + terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate, + terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority + }) +} + +type RendererBufferStub = { data: string; cols: number; rows: number } + +function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { + const runtime = new OrcaRuntimeService(store) + const replies: { ptyId: string; data: string }[] = [] + runtime.setPtyController({ + write: (ptyId, data) => { + replies.push({ ptyId, data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true, + ...(opts.rendererBuffer + ? { + hasRendererSerializer: () => true, + serializeBuffer: async () => opts.rendererBuffer ?? null + } + : {}) + }) + return { runtime, replies } +} + +/** Awaits the per-PTY emulator writeChain so queued chunk links (and the + * replies they forward) have settled. */ +async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise { + await runtime.serializeMainTerminalBuffer(ptyId) +} + +/** Renderer-pushed attribute snapshot with distinct, pinned slot values so + * reply fixtures cannot pass by coincidence. */ +function viewAttributes(overrides: Partial = {}): TerminalViewAttributes { + const ansi = Array.from( + { length: 256 }, + (_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb + ) + ansi[1] = [0xcc, 0x00, 0x00] + return { + foreground: [0xd0, 0xd0, 0xd0], + background: [0x1e, 0x1e, 0x2e], + cursor: [0xff, 0x99, 0x00], + ansi, + colorSchemeMode: 'dark', + cursorStyle: 'bar', + cursorBlink: true, + ...overrides + } +} + +afterEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() + settingsState.terminalMainSideEffectAuthority = true + settingsState.terminalHiddenDeliveryGate = true + settingsState.terminalModelQueryAuthority = true +}) + +describe('reply parity for hidden-dropped chunks', () => { + // Expected replies pinned from the design doc and verified against the + // bundled @xterm/headless build — the same core the renderer runs, so + // parity is structural for static and model-state classes. + it.each([ + ['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']], + ['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']], + ['DA2', '\x1b[>c', ['\x1b[>0;276;0c']], + ['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']], + ['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']], + ['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']], + ['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']], + ['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']], + ['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']], + ['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']], + ['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']], + ['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']], + ['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']], + ['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']], + ['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']], + ['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']], + ['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']], + ['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']], + ['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']], + ['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']], + ['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']], + ['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']], + ['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']], + ['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']], + ['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']], + ['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']], + ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], + ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + }) + + it.each([ + ['XTWINOPS', '\x1b[14t'], + ['XTGETTCAP', '\x1bP+q544e\x1b\\'], + ['DSR ?15n printer status', '\x1b[?15n'], + ['DSR ?25n UDK status', '\x1b[?25n'], + ['DSR ?26n keyboard status', '\x1b[?26n'], + ['DSR ?53n locator status', '\x1b[?53n'], + // View-attribute class: silent until the slice-2 renderer attribute push + // — a fabricated default would resurrect the default-black OSC-11 bug. + ['OSC 10 foreground query', '\x1b]10;?\x07'], + ['OSC 11 background query', '\x1b]11;?\x07'], + ['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'], + ['OSC 4 palette query', '\x1b]4;1;?\x07'], + ['DSR ?996n color-scheme query', '\x1b[?996n'] + ])('stays silent for %s', async (_label, chunk) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies).toEqual([]) + }) +}) + +describe('reply ownership matrix', () => { + const DA1 = '\x1b[c' + + it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-v', DA1, Date.now()) + await settle(runtime, 'pty-v') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-i') + setRendererPtyDeliveryInterest('pty-i', true) + + runtime.onPtyData('pty-i', DA1, Date.now()) + await settle(runtime, 'pty-i') + + expect(replies).toEqual([]) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-k') + + runtime.onPtyData('pty-k', DA1, Date.now()) + await settle(runtime, 'pty-k') + + expect(replies).toEqual([]) + }) + + it('yields while a remote view subscriber is attached and resumes on release', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-r') + const release = runtime.registerRemoteTerminalViewSubscriber('pty-r') + + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies).toEqual([]) + + release() + // Releases are idempotent: a double release must not unbalance the count. + release() + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('counts overlapping remote view subscribers', () => { + const { runtime } = createResponderRuntime() + const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m') + const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m') + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseA() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseB() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) + }) + + it('treats mobile subscriber records as remote view subscribers', async () => { + const { runtime } = createResponderRuntime() + await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true) + }) + + it('answers a dropped-chunk query exactly once', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-once') + + runtime.onPtyData('pty-once', DA1, Date.now()) + await settle(runtime, 'pty-once') + + expect(replies).toHaveLength(1) + }) +}) + +describe('main-side replay guard', () => { + const DA1 = '\x1b[c' + + it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seed') + + runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`) + await settle(runtime, 'pty-seed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seed', DA1, Date.now()) + await settle(runtime, 'pty-seed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('never answers queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-hyd') + + // First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration + // seed parses the embedded DA1 but must not forward its reply. + runtime.onPtyData('pty-hyd', 'live output', Date.now()) + await settle(runtime, 'pty-hyd') + + expect(replies).toEqual([]) + }) +}) + +describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => { + it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty') + + // Daemon warm-reattach threads modes.kittyKeyboardFlags through the + // spawn result into the seed; applying them is a seed-side write and + // must answer no one (main-side replay guard). + runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, { + kittyKeyboardFlags: 5 + }) + await settle(runtime, 'pty-kitty') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u']) + }) + + it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty0') + + runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt') + runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty0') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u']) + }) +}) + +describe('ingestion-time ownership capture', () => { + const DA1 = '\x1b[c' + + it('still answers when the hidden mark flips off between ingestion and the async write', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-race') + + runtime.onPtyData('pty-race', DA1, Date.now()) + // Flip before the queued writeChain link runs: the captured decision wins. + unmarkHiddenRendererPty('pty-race') + await settle(runtime, 'pty-race') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('stays silent when the hidden mark lands after ingestion', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-race2', DA1, Date.now()) + markHiddenRendererPty('pty-race2') + await settle(runtime, 'pty-race2') + + expect(replies).toEqual([]) + }) +}) + +describe('stale writeChain links after dispose', () => { + const DA1 = '\x1b[c' + + it('never forwards a queued reply once the PTY state is disposed', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-stale') + + // Queue a forward-flagged chain link, then dispose before it runs. + runtime.onPtyData('pty-stale', DA1, Date.now()) + runtime.onPtyExit('pty-stale', 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(replies).toEqual([]) + }) + + it('never injects a stale reply into a successor PTY reusing the session id', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-reuse') + + // Daemon respawns reuse session ids: dispose with the flagged link still + // queued, then re-create the same id before the link runs. + runtime.onPtyData('pty-reuse', DA1, Date.now()) + runtime.onPtyExit('pty-reuse', 0) + runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now()) + await settle(runtime, 'pty-reuse') + + expect(replies).toEqual([]) + }) +}) + +describe('ConPTY DA1 override', () => { + it('retrofits the override when the spawn mark lands after data created the emulator', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-win-late') + + // Daemon warm-reattach flush: stream data creates the emulator before + // the awaited spawn response marks the PTY native-Windows. + runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now()) + markNativeWindowsConptyPty('pty-win-late') + + runtime.onPtyData('pty-win-late', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-late') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('keeps the override single-reply when installed at creation and marked again', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win-idem') + markHiddenRendererPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', 'boot output', Date.now()) + // A duplicate mark (e.g. respawn against a live emulator) must not stack + // a second handler that double-replies. + markNativeWindowsConptyPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win') + markHiddenRendererPty('pty-win') + + runtime.onPtyData('pty-win', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('lets non-primary device-attribute queries fall through to the core', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win2') + markHiddenRendererPty('pty-win2') + + runtime.onPtyData('pty-win2', '\x1b[>c', Date.now()) + await settle(runtime, 'pty-win2') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c']) + }) + + it('keeps the override silent for delivered chunks', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win3') + + runtime.onPtyData('pty-win3', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win3') + + expect(replies).toEqual([]) + }) +}) + +describe('HeadlessEmulator forwarding window', () => { + it('forwards replies only for writes flagged forwardQueryReplies', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + try { + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c') + } finally { + emulator.dispose() + } + }) + + it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + // Force the async write path (xterm deprecates writeSync; the fallback + // must stay structurally safe without writeChain serialization). + const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } } + internals.terminal._core.writeSync = undefined + try { + // Enqueue an unflagged seed carrying a query, then a flagged live + // chunk, WITHOUT awaiting between them: both sit in xterm's write + // queue together. The seed parse must not see an open window. + const seed = emulator.write('seeded\x1b[c') + const live = emulator.write('\x1b[5n', { forwardQueryReplies: true }) + await Promise.all([seed, live]) + + expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n']) + } finally { + emulator.dispose() + } + }) + + it('keeps the ConPTY override inside the forwarding window', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + emulator.installConptyPrimaryDeviceAttributesOverride() + try { + // Unflagged (replayed/seeded) DA1 must answer no one even with the + // override installed. + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c') + } finally { + emulator.dispose() + } + }) +}) + +describe('view-attribute bridge replies (after renderer push)', () => { + // Reply bytes pinned to the renderer xterm's format: OSC replies use the + // queried ident, 16-bit doubled-byte channels, and ST termination + // (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with + // the contour 997 report, same bytes as mode2031SequenceFor. + it.each([ + ['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']], + ['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']], + ['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']], + ['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']], + ['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']], + [ + 'OSC 4 multiple slots in one sequence', + '\x1b]4;1;?;196;?\x07', + ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'] + ], + [ + 'OSC 10 stacked params report foreground then background', + '\x1b]10;?;?\x07', + ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'] + ], + ['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']], + ['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']], + ['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-view') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-view', chunk, Date.now()) + await settle(runtime, 'pty-view') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + }) + + it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-dark') + // Supported divergence: light app mode with terminalUseSeparateLightTheme + // off renders a dark terminal theme. A visible xterm answers ?996n from + // bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so + // the hidden reply must say dark here too. + setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' })) + + runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-dark') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n']) + }) + + it('answers ?996n light for a light palette regardless of the pushed app mode', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-light') + setTerminalViewAttributes( + viewAttributes({ + foreground: [0x33, 0x33, 0x33], + background: [0xfa, 0xfa, 0xfa], + colorSchemeMode: 'dark' + }) + ) + + runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-light') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => { + // _reportColorScheme reads the CURRENT theme-service colors, which include + // OSC 10/11 SET mutations — the per-PTY overlays layer the same way. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-set') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-set') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('stays silent before the first push, then answers the same query after it', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-first') + + runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-first') + // No fabricated defaults: silence is the documented hidden status quo. + expect(replies).toEqual([]) + + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-first') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('retrofits cursor options onto already-live emulators when the push lands late', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-late') + + // Emulator exists before any push: core default DECSCUSR is steady block. + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\']) + + setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false })) + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\') + }) +}) + +describe('per-PTY OSC color SET layering', () => { + it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-a') + markHiddenRendererPty('pty-b') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now()) + runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-a') + await settle(runtime, 'pty-b') + + expect(replies).toEqual([ + { ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' }, + { ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' } + ]) + }) + + it('restores a single indexed color via OSC 104;', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-104') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']) + }) + + it('restores the whole indexed table via bare OSC 104', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104all') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-104all', + '\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07', + Date.now() + ) + await settle(runtime, 'pty-104all') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]4;1;rgb:cccc/0000/0000\x1b\\', + '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\' + ]) + }) + + it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-special') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-special', + '\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' + + '\x1b]110\x07\x1b]111\x07\x1b]112\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07', + Date.now() + ) + await settle(runtime, 'pty-special') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]10;rgb:0101/0202/0303\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\', + '\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', + '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', + '\x1b]12;rgb:ffff/9999/0000\x1b\\' + ]) + }) + + it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => { + // Cold-restore scrollback replayed into a visible renderer xterm re-applies + // OSC SETs to its theme service; the model mirrors that state — but the + // replay guard still keeps the seed from ANSWERING anything. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seedset') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07') + await settle(runtime, 'pty-seedset') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-seedset') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\']) + }) + + it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-idem') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-idem') + + // A second window / renderer reload / macOS re-activation re-pushes + // byte-identical attributes (its publisher dedupe is per-process). That is + // not a theme apply, so the OSC SET overlay must survive. + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\']) + }) + + it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-clear') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-clear') + + // A theme apply overwrites OSC-SET-mutated colors on visible panes too + // (ThemeService._setTheme), so the model mirrors that on every CHANGED + // push (identical re-pushes are filtered — see the test above). + setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] })) + runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-clear') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\']) + }) +}) + +describe('view-attribute replay guard and suppression', () => { + it('never answers view-attribute queries embedded in a seeded snapshot', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vseed') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n') + await settle(runtime, 'pty-vseed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vseed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-vhyd') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vhyd', 'live output', Date.now()) + await settle(runtime, 'pty-vhyd') + + expect(replies).toEqual([]) + }) + + it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => { + const { runtime, replies } = createResponderRuntime() + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vvis') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vint') + setRendererPtyDeliveryInterest('pty-vint', true) + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vint') + + expect(replies).toEqual([]) + }) + + it('yields view-attribute replies while a remote view subscriber is attached', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vrem') + setTerminalViewAttributes(viewAttributes()) + const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem') + + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies).toEqual([]) + + release() + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vkill') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vkill') + + expect(replies).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -0,0 +1,57 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * push. One app-global snapshot, not per-PTY — per-pane font zoom never + * affects these attributes and the color/cursor settings are global. + * + * Null until the first push, and the responder answers NO view-attribute + * query while null (silent-until-first-push): a fabricated default would + * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC + * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + */ +import { + terminalViewAttributesEqual, + type TerminalViewAttributes +} from '../../shared/terminal-view-attributes' + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives +// the push, the runtime emulators consult it at reply time via the getter. +let currentAttributes: TerminalViewAttributes | null = null + +// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push +// must also reach already-live emulators — cursor options under the replay +// guard, plus the per-PTY override reset a theme apply implies. +type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void +const pushAppliers = new Set() + +export function registerTerminalViewAttributesApplier( + applier: TerminalViewAttributesApplier +): void { + pushAppliers.add(applier) +} + +/** Called from the pty:terminalViewAttributes IPC handler with a validated + * payload. Last push wins (replies always use the freshest snapshot). */ +export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void { + // Why idempotent: the renderer publisher's dedupe is per-process, so a + // fresh renderer (second window, reload, macOS re-activation) re-pushes + // identical attributes. That is not a theme apply — fanning out would wipe + // every PTY's OSC SET overlay while visible panes keep theirs. + if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { + return + } + currentAttributes = attributes + for (const applier of pushAppliers) { + applier(attributes) + } +} + +export function getTerminalViewAttributes(): TerminalViewAttributes | null { + return currentAttributes +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalViewAttributesForTest(): void { + currentAttributes = null + pushAppliers.clear() +} diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts new file mode 100644 index 00000000000..0b4e8188c07 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -0,0 +1,48 @@ +import { vi, type Mock } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { SshConnection } from './ssh-connection' +import type { Store } from '../persistence' +import type { SshPortForwardManager } from './ssh-port-forward' +import { deployAndLaunchRelay } from './ssh-relay-deploy' + +type SshRelaySessionTestDeps = { + mockConn: SshConnection + mockStore: Store + mockPortForward: SshPortForwardManager + getMainWindow: Mock<() => BrowserWindow | null> + mockWindow: BrowserWindow +} + +export function createMockDeps(): SshRelaySessionTestDeps { + const mockConn = {} as SshConnection + const mockStore = { + getRepos: vi.fn().mockReturnValue([]), + getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyLeases: vi.fn() + } as unknown as Store + const mockPortForward = { + removeAllForwards: vi.fn() + } as unknown as SshPortForwardManager + const mockWindow = { + isDestroyed: () => false, + // Why: the port scanner visibility-gates its ticks; a visible mock window + // keeps establish-path tests exercising the scan-on-ready behavior. + isVisible: () => true, + isMinimized: () => false, + webContents: { send: vi.fn() } + } as unknown as BrowserWindow + const getMainWindow = vi.fn().mockReturnValue(mockWindow) + return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } +} + +export function mockDeploySuccess(): void { + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }, + platform: 'linux-x64' + }) +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index aebfd234b5c..82cbfaee42c 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -90,6 +89,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +// Why: the hidden-delivery gate module is intentionally real (pure state, no +// electron deps) so the SSH parity tests exercise the same gate main uses. +const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = + await import('../ipc/pty-hidden-delivery-gate') +const { _resetHiddenRendererPtyDeliveryGateForTest } = + await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -105,41 +110,6 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = const { registerSshGitProvider, unregisterSshGitProvider } = await import('../providers/ssh-git-dispatch') -function createMockDeps() { - const mockConn = {} as SshConnection - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() - } as unknown as Store - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: () => false, - // Why: the port scanner visibility-gates its ticks; a visible mock window - // keeps establish-path tests exercising the scan-on-ready behavior. - isVisible: () => true, - isMinimized: () => false, - webContents: { send: vi.fn() } - } - const getMainWindow = vi.fn().mockReturnValue(mockWindow) - return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } -} - -function mockDeploySuccess() { - const mockTransport = { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: mockTransport, - platform: 'linux-x64' - }) -} - describe('SshRelaySession', () => { beforeEach(() => { vi.clearAllMocks() @@ -150,6 +120,95 @@ describe('SshRelaySession', () => { installRemoteManagedAgentHooksMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { + onPtyData: vi.fn(() => 99), + onPtyExit: vi.fn() + } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) + + // Runtime ingestion still ran; renderer delivery shrank to one marker. + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh-pty-1', + 'hidden ssh output', + expect.any(Number) + ) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh-pty-1', + reason: 'hidden-drop', + markerSeq: 99 + }) + + onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Delivery interest (renderer sidecars) suppresses the gate — parity with + // the local path in ipc/pty.ts. + setRendererPtyDeliveryInterest('ssh-pty-1', true) + onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'sidecar ssh bytes', + seq: 99, + rawLength: 'sidecar ssh bytes'.length + }) + + // Non-hidden PTYs are unaffected. + onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-2', + data: 'visible ssh output', + seq: 99, + rawLength: 'visible ssh output'.length + }) + }) + + it('keeps hidden SSH delivery when the gate kill switch is off', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ + terminalHiddenDeliveryGate: false + })) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'still delivered' }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'still delivered' + }) }) it('starts in idle state', () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b2564259ffd..5176d06411f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -44,6 +44,11 @@ import { setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -1080,7 +1085,30 @@ export class SshRelaySession { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) const win = this.getMainWindow() - if (win && !win.isDestroyed() && rendererData.length > 0) { + if (!win || win.isDestroyed()) { + return + } + // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion + // above already consumed the chunk; gated renderer delivery is dropped + // and one out-of-band pty:modelRestoreNeeded signal latches + // model-restore-needed for reveal. Never an in-band pty:data sentinel: + // OSC-9999-only chunks legitimately strip to empty in the renderer. + const store = this.store as { getSettings?: Store['getSettings'] } + if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + win.webContents.send('pty:modelRestoreNeeded', { + id: payload.id, + reason: 'hidden-drop', + ...(typeof seq === 'number' ? { markerSeq: seq } : {}) + } satisfies PtyModelRestoreNeededEvent) + } + return + } + // Why: startup color-query answering can strip query-only chunks to + // empty; skip empty sends and only attach seq metadata when the chunk + // reaches the renderer unmodified (seq tracks raw stream offsets). + if (rendererData.length > 0) { win.webContents.send('pty:data', { ...payload, data: rendererData, diff --git a/src/main/synthetic-title-frame-routing.test.ts b/src/main/synthetic-title-frame-routing.test.ts new file mode 100644 index 00000000000..b4bbf35ecd2 --- /dev/null +++ b/src/main/synthetic-title-frame-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' + +describe('shouldCopySyntheticTitleFrameToPtyData', () => { + it('keeps the legacy pty:data copy only while the kill switch is off', () => { + // Authority off: renderer byte parsers are the sole synthetic-frame + // consumer, so the legacy copy must keep flowing. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe( + true + ) + }) + + it('skips the copy under main authority — tracker ingest is the only consumer', () => { + // Why: under authority the copy would only mint phantom renderer ACKs + // for fabricated bytes main never metered. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe( + false + ) + // Default-on: an unset switch means main authority. + expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false) + }) +}) diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts new file mode 100644 index 00000000000..3bb10a030fa --- /dev/null +++ b/src/main/synthetic-title-frame-routing.ts @@ -0,0 +1,14 @@ +import type { GlobalSettings } from '../shared/types' + +/** + * Why: with the side-effect kill switch off, renderer byte parsers are the + * ONLY consumer of main-fabricated OSC title frames, so they must still ride + * `pty:data`. With main authority on (the default), the tracker ingest is the + * sole consumer and the legacy copy would only mint phantom renderer ACKs for + * bytes main never metered. See terminal-side-effect-authority.md (slice 3). + */ +export function shouldCopySyntheticTitleFrameToPtyData( + settings: Pick | null | undefined +): boolean { + return settings?.terminalMainSideEffectAuthority === false +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 453487a18aa..dc278cb1ffa 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,8 @@ const { menuPopupMock, notificationMock, notificationShowMock, + powerMonitorOnMock, + powerMonitorRemoveListenerMock, isMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() @@ -23,6 +25,8 @@ const { return { show: notificationShowMock } }), notificationShowMock, + powerMonitorOnMock: vi.fn(), + powerMonitorRemoveListenerMock: vi.fn(), isMock: { dev: false } } }) @@ -34,6 +38,7 @@ vi.mock('electron', () => ({ Menu: { buildFromTemplate: buildFromTemplateMock }, Notification: notificationMock, nativeTheme: { shouldUseDarkColors: false }, + powerMonitor: { on: powerMonitorOnMock, removeListener: powerMonitorRemoveListenerMock }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) }, @@ -77,6 +82,8 @@ describe('createMainWindow', () => { menuPopupMock.mockClear() notificationMock.mockClear() notificationShowMock.mockClear() + powerMonitorOnMock.mockReset() + powerMonitorRemoveListenerMock.mockReset() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() @@ -2853,6 +2860,103 @@ describe('createMainWindow', () => { }) }) + describe('system resume relay', () => { + function setupResumeWindow() { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDestroyed: vi.fn(() => false), + id: 1 + } + const instance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + // Why: maximized keeps forceRepaint from scheduling its size-nudge timer. + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, webContents, instance } + } + + function getPowerResumeListener(): () => void { + const resumeCall = powerMonitorOnMock.mock.calls.find( + (call: unknown[]) => call[0] === 'resume' + ) + if (!resumeCall) { + throw new Error('missing powerMonitor resume listener') + } + return resumeCall[1] as () => void + } + + it('relays powerMonitor resume to the live window and forces a repaint', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).toHaveBeenCalledWith('system:resumed') + expect(webContents.invalidate).toHaveBeenCalledTimes(1) + }) + + it('does not send the resume event once the window is destroyed', () => { + const { webContents, instance } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + instance.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('does not send the resume event once webContents is destroyed', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('removes the powerMonitor resume listener when the window closes', () => { + const { windowHandlers } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + + windowHandlers.closed() + + expect(powerMonitorRemoveListenerMock).toHaveBeenCalledWith('resume', onResume) + }) + }) + describe('minimize to tray on close (win32)', () => { const originalPlatform = process.platform diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 44b246e8980..1a30178a4e2 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -6,6 +6,7 @@ import { Menu, nativeTheme, Notification, + powerMonitor, screen, shell } from 'electron' @@ -313,6 +314,19 @@ export function createMainWindow( }) } + // Why: a focus-preserving system/display wake fires no window focus or + // visibility events in the renderer, so terminal wake recovery would never + // run. Relay powerMonitor resume explicitly (supported on mac/win/linux) + // and force a repaint so stale compositor surfaces recover too. + const onSystemResume = (): void => { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { + return + } + forceRepaint(mainWindow) + mainWindow.webContents.send('system:resumed') + } + powerMonitor.on('resume', onSystemResume) + mainWindow.webContents.on('dom-ready', () => { const level = store?.getUI().uiZoomLevel ?? 0 mainWindow.webContents.setZoomLevel(level) @@ -1203,6 +1217,9 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + // Why: powerMonitor is app-global; without this the closed window's + // resume relay would leak and fire against a destroyed webContents. + powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` // after its webContents has already been destroyed. The destroyed diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e278d863e21..ceb0d6a8d54 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -206,22 +206,18 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' - -type GitLabRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type GitHubRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource } from '../shared/terminal-custom-themes' + import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -292,6 +288,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -445,6 +442,18 @@ import type { } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type GitHubRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + export type BrowserApi = { registerGuest: (args: { browserPageId: string @@ -1206,6 +1215,10 @@ export type PreloadApi = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md — main // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before // pty:spawn returns. Only the renderer's daemon-host path threads these. @@ -1237,12 +1250,34 @@ export type PreloadApi = { clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void - ackData: (id: string, charCount: number) => void + ackData: (id: string, charCount: number, processedChars?: number) => void + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }) => void + /** Renderer-initiated delivery health/heal lane over invoke — reaches main + * even when every main→renderer push channel is dead (field wedge). */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ) => Promise + /** Live pty:data listener count on the preload emitter (sync) — heal-time + * discriminator between a detached listener and a dead channel. */ + getPtyDataListenerCount: () => number /** One-shot signal that this page's pty:data dispatcher is registered, so * main can release sends held during the load/reload boot window. */ rendererDispatcherReady: () => void setActiveRendererPty: (id: string, active: boolean) => void setRendererPtyVisible: (id: string, visible: boolean) => void + /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer + * byte delivery after model ingestion; reveal restores from snapshots. */ + setHiddenRendererPty: (id: string, hidden: boolean) => void + /** Ref-counted-on-the-renderer delivery-interest signal that suppresses + * the hidden-delivery gate while any raw-byte consumer is registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -1258,8 +1293,16 @@ export type PreloadApi = { rows: number cwd?: string | null seq?: number + /** Start of main's pending renderer-delivery queue at snapshot time + * (equals `seq` when empty) — bounds the renderer's post-restore + * duplicate window. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape the emulator ingested; the restorer must + * write it after its post-replay resets, last before live chunks. */ pendingEscapeTailAnsi?: string } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ @@ -1276,6 +1319,14 @@ export type PreloadApi = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -1289,10 +1340,19 @@ export type PreloadApi = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Out-of-band main→renderer signal that renderer-bound bytes were + * dropped (hidden-delivery gate / pending cap); the pane restores from + * the model snapshot. Never delivered in-band on pty:data. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void + /** Batched derived side-effect facts for PTYs whose bytes transit local + * main; see docs/reference/terminal-side-effect-authority.md. */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Title-only replay snapshot for (re)attach; attention facts never replay. */ + getSideEffectSnapshot: (id: string) => Promise onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( callback: (data: { @@ -1993,6 +2053,10 @@ export type PreloadApi = { telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise + /** Synchronous persisted-settings read for startup decisions that cannot + * wait for async hydration (terminal side-effect authority). Blocking + * IPC — call sparingly. */ + getSync: () => GlobalSettings | null set: (args: Partial) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index 079bdceb181..f1cdf668ce3 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V export const preloadE2EConfig = createE2EConfig({ headless: process.env.ORCA_E2E_HEADLESS === '1', exposeStore, - userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null + userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null, + // Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so + // only a real positive override reaches the renderer parking policy. + terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f24dc18217..2e960b9c461 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,13 @@ import type { WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource @@ -131,6 +138,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -792,6 +800,10 @@ const api = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -851,9 +863,39 @@ const api = { ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, - ackData: (id: string, charCount: number): void => { - ipcRenderer.send('pty:ackData', { id, charCount }) + /** charCount is the legacy per-chunk delta; processedChars is the + * cumulative per-pty total (self-healing under lost ACK messages). */ + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) }, + /** Main asks for the renderer's cumulative processed totals when terminal + * delivery looks stuck on lost ACKs. */ + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + /** Renderer-initiated delivery health/heal lane. Rides invoke because the + * field wedge (v1.4.121-rc.0 snapshot) kills main→renderer push events + * while invoke stays alive — push-initiated recovery can't reach it. */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + /** Sync count of live pty:data listeners on this preload's emitter — the + * watchdog's "listener detached" vs "channel dead" discriminator. */ + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), rendererDispatcherReady: (): void => { ipcRenderer.send('pty:rendererDispatcherReady') }, @@ -863,6 +905,24 @@ const api = { setRendererPtyVisible: (id: string, visible: boolean): void => { ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) }, + /** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer + * byte delivery after model ingestion; reveal restores from the model + * snapshot. Fire-and-forget like setActiveRendererPty. */ + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + /** Delivery-interest signal: any renderer party that needs raw bytes + * (dispatcher sidecars, eager pre-mount buffers) suppresses the + * hidden-delivery gate for that PTY while registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push that lets main's model responder answer OSC 4/10/11/12 + * and DSR ?996n for hidden-gated PTYs with renderer-true values. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), @@ -880,8 +940,10 @@ const api = { rows: number cwd?: string | null seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), @@ -899,6 +961,14 @@ const api = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -934,7 +1004,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ): (() => void) => { const listener = ( @@ -945,7 +1015,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } ) => callback(data) ipcRenderer.on('pty:data', listener) @@ -959,6 +1029,32 @@ const api = { return () => ipcRenderer.removeListener('pty:replay', listener) }, + /** Out-of-band signal that main dropped renderer-bound bytes for a PTY + * (hidden-delivery gate / pending cap) — the pane must restore from the + * model snapshot. Deliberately NOT on pty:data: an in-band marker is + * ambiguous with chunks fully stripped by OSC-9999 cleaning. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + + /** Batched derived side-effect facts (title/bell/agent transitions) for + * PTYs whose bytes transit local main. Per-PTY in-order; deliberately not + * synchronized with pty:data (terminal-side-effect-authority.md). */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + + /** Title-only replay snapshot applied on (re)attach — attention facts + * (bells/completions) never replay. */ + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => callback(data) @@ -1739,6 +1835,10 @@ const api = { settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), + // Why: blocking read for the few startup decisions (terminal side-effect + // authority) that cannot wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + set: (args: Record): Promise => ipcRenderer.invoke('settings:set', args), @@ -3506,11 +3606,6 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, - onSystemResumed: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => @@ -3576,6 +3671,14 @@ const api = { ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, + /** Fired when the OS resumes from sleep (main relays powerMonitor). A + * focus-preserving display wake fires no renderer focus/visibility + * events, so terminal wake recovery listens to this explicit signal. */ + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ minimize: (): void => { ipcRenderer.send('window:minimize') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ce443c55454..eeaff50abb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -130,6 +130,8 @@ import { } from './startup/startup-diagnostics' import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' +import { getSystemPrefersDark } from './lib/terminal-theme' +import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -176,6 +178,11 @@ import { subscribeBackgroundTerminalWorktreeMountRequests } from './components/terminal/background-terminal-worktree-mount' +// Why: agents alive during a hard kill (crash, forced update install) need a +// reasonably fresh resume record on disk; one minute bounds the lost window +// without measurable per-tick cost (the capture skips unchanged records). +const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 + const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -886,6 +893,14 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) + // Why here: hidden-at-launch PTYs (background terminal reconnects, + // agent sessions) can query OSC 10/11 before any terminal pane mounts + // and main's responder is silent-until-first-push. Publish composed + // view attributes as soon as settings exist, before any spawn below. + publishTerminalViewAttributesAtAppStart( + useAppStore.getState().settings, + getSystemPrefersDark() + ) // Why: keybindings + onboarding are main-side reads with no dependency // on the catalog/session steps below, so start them now and await them // at their original positions — the round-trips overlap the local @@ -1365,6 +1380,22 @@ function App(): React.JSX.Element { return () => window.removeEventListener('beforeunload', captureAndFlush) }, []) + // Why: beforeunload never fires on a hard kill (crash, forced update + // install, TerminateProcess), so agents alive at that moment would leave no + // resume record. This periodic capture stores only agent session ids — not + // scrollback, see the no-periodic-scrollback note below — and the store + // action skips unchanged records, so idle ticks write nothing; real changes + // flow through the debounced session-write subscriber. + useEffect(() => { + const timer = window.setInterval(() => { + if (!shouldPersistWorkspaceSession(useAppStore.getState())) { + return + } + useAppStore.getState().captureAllSleepingAgentSessions() + }, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS) + return () => window.clearInterval(timer) + }, []) + // Own the single window-close-request subscription at the always-mounted App // root. Why: the rich confirmation flow lives in Terminal, which is not // mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index c4d331697c2..f8ae284a365 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -73,6 +73,18 @@ import { import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + type TerminalWorktreeColdParkCandidate +} from './terminal-pane/terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-pane/terminal-parked-tab-watchers' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' import CodexRestartChip from './CodexRestartChip' @@ -132,6 +144,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set([ type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + function findUnifiedTabByVisibleId( state: TerminalStoreSnapshot, worktreeId: string, @@ -214,6 +238,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) const allWorktrees = useAllWorktrees() const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const workspaceSurfaces = useMemo( @@ -230,6 +256,8 @@ function Terminal(): React.JSX.Element | null { const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const activeTabId = useAppStore((s) => s.activeTabId) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) const createTab = useAppStore((s) => s.createTab) @@ -737,11 +765,15 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( + () => new Set() + ) // Why: background-mounted worktrees restricted to specific tabs (targeted // wake/resume) must not instantiate a TerminalPane per saved tab. A worktree // absent from this map mounts all of its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) - const [, setBackgroundMountRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -798,6 +830,122 @@ function Terminal(): React.JSX.Element | null { closeDialogDebounceTimers.clear() } }, []) + + useEffect(() => { + const timers = terminalWorktreeParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const parkingTimers = terminalWorktreeParkingTimersRef.current + for (const timer of parkingTimers.values()) { + window.clearTimeout(timer) + } + parkingTimers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) + const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) + for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { + if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } + } + + const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] + for (const workspace of workspaceSurfaces) { + const worktreeId = workspace.id + if (!mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + continue + } + const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId) + const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId) + if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs) + } + + retentionCandidates.push({ + worktreeId, + terminalTabs: tabsByWorktree[worktreeId] ?? [], + isVisible, + shouldMeasureHiddenWorktree, + hasActivityTerminalPortal, + hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null + }) + } + + const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({ + worktrees: retentionCandidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a worktree with any tab the byte watchers cannot cover (no + // capture, no layout snapshot, legacy leaf ids) must never park — it + // would go silent for bells/titles/completions, the failure that sank + // the first parking attempt. + for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { + const tabs = tabsByWorktree[worktreeId] ?? [] + if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) { + nextParkedTerminalWorktreeIds.delete(worktreeId) + } + } + setParkedTerminalWorktreeIds((current) => + haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds) + ? current + : nextParkedTerminalWorktreeIds + ) + + for (const candidate of retentionCandidates) { + if ( + candidate.isVisible || + candidate.shouldMeasureHiddenWorktree || + candidate.hasActivityTerminalPortal || + nextParkedTerminalWorktreeIds.has(candidate.worktreeId) + ) { + continue + } + const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const worktreeId = candidate.worktreeId + const timer = window.setTimeout(() => { + parkingTimers.delete(worktreeId) + setTerminalParkingRevision((revision) => revision + 1) + }, delayMs) + parkingTimers.set(worktreeId, timer) + } + } + }, [ + activeView, + activityTerminalPortals, + backgroundMountRevision, + pendingStartupByTabId, + renderedActiveWorktreeId, + tabsByWorktree, + terminalParkingEnabled, + terminalParkingRevision, + workspaceSurfaces + ]) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId @@ -829,6 +977,59 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + // Why: parked byte-watcher reconciliation for the legacy (non-split) + // terminal host, which renders TerminalPanes directly. In split mode each + // TerminalPaneOverlayLayer owns its worktree's watchers, so here we only + // dispose worktrees that render no overlay layer (no layout / unmounted) + // and prune watchers for deleted worktrees. + useEffect(() => { + pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + for (const workspace of workspaceSurfaces) { + if ( + anyMountedWorktreeHasLayout && + mountedWorktreeIdsRef.current.has(workspace.id) && + getEffectiveLayoutForWorktree(workspace.id) + ) { + continue + } + const tabs = tabsByWorktree[workspace.id] ?? [] + const parkedTabIds = new Set() + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { + const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const parked = + !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + if (parked) { + for (const tab of tabs) { + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: workspace.id, + tabId: tab.id + }) + if (!activityTerminalPortal) { + parkedTabIds.add(tab.id) + } + } + } + } + syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds }) + } + }, [ + activeView, + activityTerminalPortals, + anyMountedWorktreeHasLayout, + backgroundMountRevision, + getEffectiveLayoutForWorktree, + parkedTerminalWorktreeIds, + renderedActiveWorktreeId, + tabsByWorktree, + workspaceSurfaces + ]) + // Why: symmetric with useTerminalTabColdParking's unmount cleanup — when + // the terminal host unmounts, no reconciliation effect will run again, so + // dispose every remaining parked watcher here (overlay-layer children have + // already disposed theirs by the time this parent cleanup runs). + useEffect(() => () => pruneParkedTerminalWatchers(new Set()), []) // Auto-create first tab when worktree activates useEffect(() => { if (!workspaceSessionReady) { @@ -1127,6 +1328,12 @@ function Terminal(): React.JSX.Element | null { if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } handleCloseTab(tabId) }, [consumeSuppressedPtyExit, handleCloseTab] @@ -1859,6 +2066,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && workspace.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIds.has(workspace.id) return ( | null }): React.JSX.Element { @@ -2207,6 +2430,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId={worktreeId} worktreePath={worktreePath} isWorktreeActive={isVisible} + coldParkTerminalPanes={shouldColdParkTerminalPanes} + shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} backgroundMountTabIds={backgroundMountTabIds} /> diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 8e1c8f17d8d..c30f5754327 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -13,6 +13,8 @@ import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' +import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' type TerminalOverlayAssignment = { unifiedTabId: string @@ -238,6 +240,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) { + return + } closeTab(terminalTabId) leaveWorktreeIfEmpty() }} @@ -279,12 +287,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + coldParkTerminalPanes = false, + shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, backgroundMountTabIds = null }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + coldParkTerminalPanes?: boolean + shouldMeasureHiddenWorktree?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] /** Non-null for targeted background mounts: only these terminal tabs get a * TerminalPane, so waking one slept agent does not connect every saved tab. */ @@ -351,6 +363,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return entries }, [groupActiveTabById, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + }) + if (!worktreePath) { return null } @@ -369,6 +391,11 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, tabId: terminalTab.id }) + // Why: parking unmounts only the view; the parked watcher owns exit + // and side-effect handling until this tab is eligible to remount. + if (parkedTerminalTabIds.has(terminalTab.id)) { + return null + } return ( | null +} + +export function isAgentTaskCompleteOsNotificationEnabledFromState( + state: NotificationSettingsState +): boolean { + const notifications = state.settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean { + return state.settings?.experimentalTerminalAttention === true +} + +/** Completion tracking runs when either consumer (OS notification or the + * experimental terminal-attention marker) is enabled. */ +export function isAgentTaskCompleteTrackingEnabledFromState( + state: NotificationSettingsState +): boolean { + return ( + isAgentTaskCompleteOsNotificationEnabledFromState(state) || + isTerminalAttentionEnabledFromState(state) + ) +} + +export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { + return Boolean( + entry && + Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && + (entry.lastAssistantMessage || entry.toolName || entry.toolInput) + ) +} + +export function canDispatchAgentNotificationAfterGrace( + entry: AgentStatusEntry | undefined, + options: { allowDoneDetailAfterGrace?: boolean } = {} +): boolean { + // Why: hook-backed goal/mission loops can report `done` between milestones. + // User-input states may notify as soon as detail arrives, but `done` waits + // for the max quiet window so resumed work can cancel the pending banner. + return ( + hasAgentNotificationDetail(entry) && + (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) + ) +} diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts new file mode 100644 index 00000000000..0c855bac3c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../../../shared/agent-tui-ansi-fuzz-stream' +import { extractPartialEscapeTail } from '../../../../shared/terminal-partial-escape-tail' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRows, + visibleRowStyles, + writeChunksToTerminal +} from '../../../../shared/terminal-restore-parity-fixture' + +// Property fuzz for the hidden-reveal seq-reconciliation path in +// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as +// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot +// captured at seq S (paints everything <= S), then stitches the racing tail +// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte. +// The byte arithmetic lives in two non-exported closures: +// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a +// pending chunk against the snapshot seq. +// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups +// backlog, detects a restarted seq domain, and heals dropped-output gaps by +// forcing a fresh restore. +// This suite mirrors those rules EXACTLY (each branch tagged with its source +// line) and asserts the invariant they guarantee: for any reveal boundary, +// snapshot(hidden prefix) + reconciled tail reproduces the same screen an +// always-visible terminal shows for the full stream. A wrong slice = a garble. +// +// Runtime knobs: +// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite) +// FUZZ_SEED=1234 re-run exactly one seed + +const DEFAULT_ITERATIONS = 200 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 } +] + +/** A metered chunk exactly as main delivers to onData: text, the seq of the + * LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers. + * domain increments across a ptyId-exit seq restart (production clears the + * restored baseline at that boundary, so the tail writes verbatim). */ +type MeteredChunk = { + data: string + seq?: number + rawLength?: number + domain: number + droppedOutput?: boolean +} + +/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of + * a chunk in the SNAPSHOT'S seq domain survives once the snapshot at + * snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice, + * force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical + * to production; each return tagged with its branch. */ +function chunkDataAfterSnapshot( + chunk: MeteredChunk, + snapshotSeq: number | undefined +): string | null { + if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { + return chunk.data // L4633: unmetered — pass through + } + const rawLength = chunk.rawLength ?? chunk.data.length + const startSeq = chunk.seq - rawLength + if (snapshotSeq >= chunk.seq) { + return '' // L4638: fully before/at snapshot — already painted + } + if (snapshotSeq <= startSeq) { + return chunk.data // L4641: fully after snapshot — keep whole + } + const offset = snapshotSeq - startSeq + if (rawLength !== chunk.data.length) { + return null // L4645: OSC-stripped, offsets unmappable + } + return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix +} + +type RevealResult = { + /** Full normal-buffer content (visible + scrollback, trailing blanks + * trimmed). Viewport-independent: a byte lost or duplicated by reconciliation + * changes this even when the visible viewport happens to line up. */ + content: string[] + rows: string[] + styles: string[] + cursor: { x: number; y: number } + /** True when the buffer scrolled (baseY > 0). The visible viewport's top + * anchor after a snapshot restore vs continuous writing can differ by a row + * or two purely from trailing-blank trimming — a snapshot-scrollback-depth + * concern owned by the fidelity suite, not seq reconciliation — so styles and + * cursor (viewport-relative) are only asserted on non-scrolled scenarios. */ + scrolled: boolean + /** True when the terminal ended on the alternate screen. Alt has no + * scrollback and a fixed viewport, so its content is the visible rows and the + * normal-buffer content comparison does not apply (the alt-screen restore + * contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */ + alternate: boolean + forcedFreshRestore: boolean + knownSerializeWrapBug: boolean +} + +async function readScreen( + term: ReturnType +): Promise { + const alternate = term.terminal.buffer.active.type === 'alternate' + return { + content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal), + rows: visibleRows(term.terminal), + styles: visibleRowStyles(term.terminal), + cursor: cursorPosition(term.terminal), + scrolled: term.terminal.buffer.active.baseY > 0, + alternate, + forcedFreshRestore: false, + knownSerializeWrapBug: false + } +} + +/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery + * index at which the pane is revealed: chunks [0, revealIdx) were captured by + * the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail. + * The snapshot seq is the seq of the last hidden chunk in the snapshot's + * domain; the tail is stitched via the production slice/reconcile rules. */ +async function revealFromSnapshot( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[], + revealIdx: number +): Promise { + const source = createRendererParityTerminal(dims) + const restored = createRendererParityTerminal(dims) + try { + // Snapshot source = every hidden chunk painted in order. + const hiddenChunks = chunks.slice(0, revealIdx).map((c) => c.data) + await writeChunksToTerminal(source.terminal, hiddenChunks) + // Snapshot seq = seq of the last hidden chunk that carried one (the seq the + // emulator would report). undefined when the hidden prefix was unmetered. + let snapshotSeq: number | undefined + let snapshotDomain = 0 + for (let i = revealIdx - 1; i >= 0; i--) { + if (typeof chunks[i]!.seq === 'number') { + snapshotSeq = chunks[i]!.seq + snapshotDomain = chunks[i]!.domain + break + } + } + const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0, { + // Mirror of HeadlessEmulator's ingest tracker: the hidden stream's + // trailing incomplete escape rides the snapshot out-of-band (Bug E fix). + pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join('')) + }) + const alt = snapshot.alternateScreen + const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal) + // Mirror of applyMainBufferSnapshot's write order: the pending escape tail + // is the FINAL replay write — any later ESC (e.g. the post-replay reset) + // would abort the dangling sequence before the racing tail completes it. + const preamble = + alt && snapshot.scrollbackAnsi !== undefined + ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + : alt + ? SNAPSHOT_REPLAY_PREAMBLE_ALT + : SNAPSHOT_REPLAY_PREAMBLE_NORMAL + await writeChunksToTerminal(restored.terminal, [ + preamble, + snapshot.data, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + ...(snapshot.pendingEscapeTailAnsi ? [snapshot.pendingEscapeTailAnsi] : []) + ]) + + // Stitch the tail. A chunk in the snapshot's domain is sliced against the + // snapshot seq (drains the painted prefix); a chunk from a later domain + // (post-exit seq restart) writes verbatim — production clears the baseline + // at the ptyId-exit boundary, so no seq dedupe applies across domains. + let forcedFreshRestore = false + for (let i = revealIdx; i < chunks.length; i++) { + const chunk = chunks[i]! + if (chunk.domain !== snapshotDomain) { + await writeChunksToTerminal(restored.terminal, [chunk.data]) + continue + } + const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq) + if (sliced === null) { + // Production re-fetches a fresh snapshot here; a fresh snapshot of the + // full stream trivially matches the always-visible screen, so mark the + // scenario as a fresh-restore (excluded from the stitch comparison). + forcedFreshRestore = true + continue + } + if (sliced) { + await writeChunksToTerminal(restored.terminal, [sliced]) + } + } + const result = await readScreen(restored) + result.forcedFreshRestore = forcedFreshRestore + result.knownSerializeWrapBug = knownSerializeWrapBug + return result + } finally { + source.terminal.dispose() + restored.terminal.dispose() + } +} + +async function alwaysVisible( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + const term = createRendererParityTerminal(dims) + try { + await writeChunksToTerminal( + term.terminal, + chunks.map((c) => c.data) + ) + return await readScreen(term) + } finally { + term.terminal.dispose() + } +} + +/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream + * (reveal at the very end, no racing tail). Sharing the snapshot machinery with + * revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore, + * Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point + * reveal is attributable purely to the tail-stitch seq arithmetic. */ +async function fullSnapshotReference( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + return revealFromSnapshot(dims, chunks, chunks.length) +} + +type Scenario = { + seed: number + dims: AgentTuiStreamDims + chunks: MeteredChunk[] + revealIdx: number + domains: number + hasDropped: boolean +} + +const TAIL_WORDS = [ + 'reading', + 'tokens 12.4k', + '你好世界', + '터미널', + '✅ done', + 'PASS test.ts', + '+142 -37', + 'diff --git' +] as const +// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an +// inverse-fg the serializer round-trips slightly differently depending on how +// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B +// class) that has nothing to do with seq reconciliation. Keeping it out of the +// tail keeps the styles gate a clean function of the byte-stitch. +const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const + +/** Append-only racing tail: SGR runs + text + newlines only. No absolute/ + * relative cursor motion, scroll regions, alt frames, or DECSC — so the tail + * paints wherever the cursor sits and its screen effect is a pure function of + * which bytes the seq-reconciliation applies. Terminal-state-loss garbles + * (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the + * seq slice. */ +function buildAppendOnlyTail(rng: () => number, lines: number): string { + const out: string[] = [] + for (let i = 0; i < lines; i++) { + const words: string[] = [] + const count = 1 + Math.floor(rng() * 3) + for (let w = 0; w < count; w++) { + words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!) + } + const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]! + out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`) + } + return out.join('') +} + +/** Builds a seeded metered stream with a random reveal boundary. A running seq + * counter tags the LAST byte of each chunk; at most ONE mid-stream restart + * bumps the domain and resets the counter low (a revived ptyId with a fresh + * main-side counter — production clears the restored baseline at that exit + * boundary, so the post-restart tail writes verbatim). Rare unmetered chunks + * (no seq) and droppedOutput markers model no-metering runtimes and pending-cap + * trims. Op counts are kept modest so most scenarios reach the full + * non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall + * back to the snapshot-depth-tolerant path (see the assertion loop). */ +function buildScenario(seed: number): Scenario { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + // Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames, + // scroll regions, DECSC…) so the SNAPSHOT is taken over rich state. + const prefixOps = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: false, + includeOscHyperlinks: false, + opCount: 3 + Math.floor(rng() * 6) + }) + const prefixStream = prefixOps.join('') + const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, { + minLen: 2, + maxLen: 64 + }) + // Racing tail: append-only content. Isolates the seq-reconciliation byte + // stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately). + const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5)) + const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, { + minLen: 2, + maxLen: 40 + }) + + const chunks: MeteredChunk[] = [] + let seq = 100 + Math.floor(rng() * 50) + let domain = 0 + let hasDropped = false + // At most one restart, placed inside the tail so the snapshot's own domain + // has a stable prefix — mirrors a single mid-session ptyId revival. + const restartAt = + rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1 + const allRaw = [...prefixRaw, ...tailRaw] + for (let i = 0; i < allRaw.length; i++) { + const data = allRaw[i]! + if (i === restartAt) { + seq = 10 + Math.floor(rng() * 20) + domain = 1 + } + const rawLength = data.length + seq += rawLength + const chunk: MeteredChunk = { data, seq, rawLength, domain } + // Rare unmetered / dropped markers, only in the tail (the prefix must meter + // so the snapshot carries a seq). + if (i >= prefixRaw.length && rng() < 0.05) { + delete chunk.seq + delete chunk.rawLength + } + if (i >= prefixRaw.length && rng() < 0.04) { + chunk.droppedOutput = true + hasDropped = true + } + chunks.push(chunk) + } + + // Reveal at or after the prefix ends, so the racing tail is purely the + // append-only region — the snapshot captures all cursor-motion/scroll/alt + // state and the tail cannot depend on unserialized terminal state. Landing + // the reveal at various points INSIDE the append-only tail exercises the + // straddle and "snapshot seq inside the tail" seq-slice cases. + const tailSpan = Math.max(1, chunks.length - prefixRaw.length) + const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan)) + return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped } +} + +function firstRowDiff(a: string[], b: string[]): number | null { + const n = Math.max(a.length, b.length) + for (let i = 0; i < n; i++) { + if ((a[i] ?? '') !== (b[i] ?? '')) { + return i + } + } + return null +} + +function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string { + return [ + `hidden-reveal reconciliation divergence — stage: ${stage}`, + `seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`, + `dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`, + `chunks: ${JSON.stringify( + s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain })) + )}`, + `expected (always-visible): ${JSON.stringify(expected)}`, + `actual (reveal-from-snapshot): ${JSON.stringify(actual)}` + ].join('\n') +} + +describe('hidden reveal seq-reconciliation fuzz', () => { + it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => { + // Pin the core invariant on a hand-built case so the property test cannot + // pass vacuously. rawLength === data.length so the straddle slices cleanly. + // Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail. + const dims = { cols: 40, rows: 6 } + const c0 = 'red line one\r\n' + const c1 = 'green straddle\r\n' + const c2 = 'plain tail rest' + const chunks: MeteredChunk[] = [ + { data: c0, seq: c0.length, rawLength: c0.length, domain: 0 }, + { data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 }, + { data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 } + ] + // Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise + // the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by + // chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1. + const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2) + const control = await alwaysVisible(dims, chunks) + expect(revealAfterC1.rows).toEqual(control.rows) + expect(revealAfterC1.styles).toEqual(control.styles) + expect(revealAfterC1.cursor).toEqual(control.cursor) + + // Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the + // tail. c1 is fully after the snapshot seq so it writes whole — identity. + const revealAtC1 = await revealFromSnapshot(dims, chunks, 1) + expect(revealAtC1.rows).toEqual(control.rows) + expect(revealAtC1.styles).toEqual(control.styles) + expect(revealAtC1.cursor).toEqual(control.cursor) + }) + + // ── Bug D regression guard: DECSC saved-cursor register across hide/reveal ── + // The serialized screen cannot carry the VT100 saved-cursor register, so a + // hidden DECSC followed by a post-reveal DECRC restored to home and the next + // writes clobbered the wrong cells. FIXED: the snapshot epilogue re-saves at + // the source's saved position before the final absolute CUP + // (serializeWithAbsoluteCursor + readSavedCursorRegister). Found by fuzz + // seed 3; mechanism in notes/garble-fuzz-divergences.md (Bug D). + it('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 4 } + // Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'. + const hidden: MeteredChunk = { + data: 'AB\x1b7\x1b[4;10HCD', + seq: 'AB\x1b7\x1b[4;10HCD'.length, + rawLength: 'AB\x1b7\x1b[4;10HCD'.length, + domain: 0 + } + // Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'. + const tail: MeteredChunk = { + data: '\x1b8X', + seq: hidden.seq! + 3, + rawLength: 3, + domain: 0 + } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix this showed 'XB' (DECRC landed at home) instead of live's 'ABX'. + expect(reveal.rows).toEqual(live.rows) + expect(reveal.cursor).toEqual(live.cursor) + }) + + // ── Bug E regression guard: snapshot boundary mid-escape-sequence ── + // A PTY read (one delivery record) can split an escape; the partial lives in + // the emulator's parser, not the serialized screen, so the racing tail's + // continuation rendered literal ('ABmCD'). FIXED: the emulator tracks the + // unparsed trailing partial (terminal-partial-escape-tail.ts) and the + // snapshot ships it out-of-band (pendingEscapeTailAnsi); the reveal replay + // re-arms it as the final write. Found by fuzz seed 4; mechanism in + // notes/garble-fuzz-divergences.md (Bug E). + it('completes an escape sequence split across the hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 3 } + // Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte). + const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 } + // Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'. + const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix the reveal showed 'ABmCD' (the 'm' became literal). + expect(reveal.rows).toEqual(live.rows) + }) + + it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => { + let statsCompared = 0 + let statsSkippedScrolled = 0 + let statsForcedFresh = 0 + let statsKnownWrapBug = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const scenario = buildScenario(seed) + // Bug E (snapshot boundary mid-escape-sequence) is no longer tolerated: + // the snapshot now carries the trailing partial escape out-of-band and + // the reveal replay re-arms it last, so these scenarios must compare + // clean like any other — a regression fails the corpus loudly. + // Primary control: a snapshot of the WHOLE stream. Sharing the snapshot + // machinery isolates the seq-reconciliation tail-stitch from + // snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S + + // tail differs from snapshot-of-everything, a byte was lost/duped/mis- + // sliced. Also compare against always-visible where the two agree, to + // catch a tail that diverges from live output. + const [reveal, reference, live] = await Promise.all([ + revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx), + fullSnapshotReference(scenario.dims, scenario.chunks), + alwaysVisible(scenario.dims, scenario.chunks) + ]) + if (reveal.forcedFreshRestore || reference.forcedFreshRestore) { + statsForcedFresh += 1 + continue + } + if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) { + statsKnownWrapBug += 1 + continue + } + if (reveal.alternate !== reference.alternate) { + expect.fail( + formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate) + ) + } + // Scrolled scenarios: top-row survival is a snapshot-depth question + // (fidelity suite), not seq reconciliation. + if (reveal.scrolled || reference.scrolled) { + statsSkippedScrolled += 1 + continue + } + statsCompared += 1 + // Seq-reconciliation gate (snapshot-fidelity-neutral). + if (firstRowDiff(reveal.content, reference.content) !== null) { + expect.fail( + formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content) + ) + } + if (firstRowDiff(reveal.rows, reference.rows) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, reference.styles) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) { + expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor)) + } + // Stronger gate: when a full-stream SNAPSHOT already matches the + // always-visible LIVE screen (no fidelity gap in play), the mid-point + // reveal must match live too — a true end-to-end garble check. + if ( + !live.scrolled && + live.alternate === reference.alternate && + firstRowDiff(reference.rows, live.rows) === null && + firstRowDiff(reference.styles, live.styles) === null && + JSON.stringify(reference.cursor) === JSON.stringify(live.cursor) + ) { + if (firstRowDiff(reveal.rows, live.rows) !== null) { + expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, live.styles) !== null) { + expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) { + expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor)) + } + } + } + // Guard against a degenerate corpus that skips its way to green: a healthy + // fraction of scenarios must reach the full non-scrolled comparison. + if (FIXED_SEED === null) { + expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2) + } + expect( + statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug + ).toBeLessThanOrEqual(ITERATIONS) + }, 120_000) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts new file mode 100644 index 00000000000..bd492d5194c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -0,0 +1,813 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const PTY_ID = 'pty-parked-1' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'repo-1::/tmp/wt-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PANE_ID = 1 +// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS. +const NOTIFICATION_GRACE_MS = 250 + +// Real agent-detection titles: braille spinner classifies as working, +// the "✳ " Claude prefix as idle, and both as Claude agents. +const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07' +const IDLE_TITLE = '✳ Build feature' +const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07` + +type MockStoreState = { + settings: { + theme?: 'system' | 'dark' | 'light' + promptCacheTimerEnabled?: boolean + experimentalTerminalAttention?: boolean + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean + notifications?: { enabled?: boolean; agentTaskComplete?: boolean } + } | null + setRuntimePaneTitle: ReturnType + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType +} + +const dispatchTerminalNotification = vi.fn() +let mockStoreState: MockStoreState + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/lib/terminal-theme', () => ({ + getSystemPrefersDark: () => true +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +function createMockStoreState(): MockStoreState { + return { + // Why: terminalMainSideEffectAuthority false pins the legacy byte-parser + // mode this suite was written for; the authority-on fact-consumer mode is + // covered by the dedicated describe block below. + settings: { + theme: 'system', + promptCacheTimerEnabled: true, + experimentalTerminalAttention: false, + terminalMainSideEffectAuthority: false, + notifications: { enabled: true, agentTaskComplete: true } + }, + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn() + } +} + +describe('startParkedTerminalByteWatcher', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onData: ((payload: { id: string; data: string }) => void) | null = null + + function emit(data: string): void { + onData?.({ id: PTY_ID, data }) + } + + // The output processor defers title/bell side effects onto a 0ms drain timer. + function flushSideEffects(): void { + vi.advanceTimersByTime(0) + } + + async function startWatcher( + overrides: Partial = {} + ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const sendInput = vi.fn() + const dispose = startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: PANE_ID, + sendInput, + ...overrides + }) + return { dispose, sendInput } + } + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + onData = null + mockStoreState = createMockStoreState() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + pty: { + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('forwards every OSC title in order to the pane and tab title store actions', async () => { + const { dispose } = await startWatcher() + + emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ + [TAB_ID, PANE_ID, '⠋ Build feature'], + [TAB_ID, PANE_ID, IDLE_TITLE] + ]) + expect(mockStoreState.updateTabTitle.mock.calls).toEqual([ + [TAB_ID, '⠋ Build feature'], + [TAB_ID, IDLE_TITLE] + ]) + dispose() + }) + + it('drops the bare cursor-agent native title before it reaches the store', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;Cursor Agent\x07') + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('does not drive the tab title when drivesTabTitle is false', async () => { + const { dispose } = await startWatcher({ drivesTabTitle: false }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('marks the exact pane unread when experimental terminal attention is enabled', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true + } + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + + expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY) + dispose() + }) + + it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;par') + emit('tial title\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('suppresses the completion OS notification when only terminal attention is on', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true, + notifications: { enabled: true, agentTaskComplete: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY, + suppressOsNotification: true + }) + dispose() + }) + + it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false, + notifications: { enabled: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + dispose() + }) + + it('lets a same-burst completion supersede the pending bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + emit(`${IDLE_TITLE_OSC}\x07`) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8) + + // The bell still marks unread immediately; only the OS notification yields. + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ source: 'agent-task-complete' }) + ) + dispose() + }) + + it('cancels the pending completion and clears the cache timer when working resumes', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + emit(WORKING_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + dispose() + }) + + it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => { + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + // theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol. + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + emit('\x1b[?2031l') + expect(sendInput).toHaveBeenCalledTimes(1) + dispose() + }) + + it('stops answering DECSET 2031 after dispose', async () => { + const { dispose, sendInput } = await startWatcher() + + dispose() + emit('\x1b[?2031h') + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('observes GitHub PR links across chunk boundaries', async () => { + const { dispose } = await startWatcher() + + emit('PR: https://github.com/orca-dev/orca/pull/42') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + + emit('1\r\ndone') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ + url: 'https://github.com/orca-dev/orca/pull/421', + number: 421, + slug: { owner: 'orca-dev', repo: 'orca' } + }) + ) + dispose() + }) + + it('fires completion when seeded with a working title and the agent goes idle while parked', async () => { + // Why: the pane was working at park time; the watcher's fresh tracker + // must be seeded or this working→idle transition can never fire. + const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('does not fire completion for an idle title without a seed or observed transition', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('clears the watcher-written runtime title slot on dispose', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + }) + + it('leaves the runtime title slot alone on dispose when it never wrote one', async () => { + const { dispose } = await startWatcher() + + emit('plain output with no titles\r\n') + flushSideEffects() + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled() + }) + + it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + + // Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds. + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // The teardown flush that main emits after pty.kill must be a no-op. + emit('final teardown flush\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('dispose unregisters the sidecar and cancels the pending bell notification', async () => { + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + // Idempotent: a second dispose must not throw or clobber another watcher. + dispose() + }) + + it('disposes the previous watcher when a new one starts for the same PTY', async () => { + await startWatcher({ paneId: 1 }) + const second = await startWatcher({ paneId: 2 }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) + second.dispose() + }) + + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on, the watcher must not register byte parsers — + // main is the single byte parser and the watcher's policy block consumes + // pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031 + // reply (query authority never moves to main); PR links arrive as facts. + describe('with main side-effect authority on', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + async function dispatchFacts( + facts: TerminalSideEffectFact[], + options: { seq?: number; replay?: boolean } = {} + ): Promise { + const handler = await import('./terminal-side-effect-facts-handler') + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: options.seq ?? 0, + ...(options.replay ? { replay: true } : {}), + facts + }) + } + + /** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip, + * shared title tracker, one fact batch per chunk — the main half of the + * migration-safety parity check. */ + async function emitViaMainTrackerFacts(chunks: string[]): Promise { + const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc') + const { createTerminalTitleTracker } = + await import('../../../../shared/terminal-output-side-effects') + const handler = await import('./terminal-side-effect-facts-handler') + const processStatusChunk = createAgentStatusOscProcessor() + let pending: TerminalSideEffectFact[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalizedTitle, rawTitle) => + pending.push({ kind: 'title', normalizedTitle, rawTitle }), + onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }), + onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }), + onAgentExited: () => pending.push({ kind: 'agent-exited' }), + onBell: () => pending.push({ kind: 'bell' }) + }) + let seq = 0 + for (const chunk of chunks) { + seq += chunk.length + tracker.handleChunk(processStatusChunk(chunk).cleanData) + if (pending.length > 0) { + handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending }) + pending = [] + } + } + tracker.dispose() + } + + type RecordedCall = [string, ...unknown[]] + + /** Wrap the policy-visible store actions so byte mode and fact mode can be + * compared as one ordered outcome sequence. Timestamps are masked. */ + function recordPolicyOutcomes(): RecordedCall[] { + const calls: RecordedCall[] = [] + mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => { + calls.push(['setRuntimePaneTitle', ...args]) + }) + mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => { + calls.push(['updateTabTitle', ...args]) + }) + mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markWorktreeUnread', ...args]) + }) + mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalTabUnread', ...args]) + }) + mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalPaneUnread', ...args]) + }) + mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => { + calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '' : at]) + }) + dispatchTerminalNotification.mockImplementation((...args: unknown[]) => { + calls.push(['dispatchTerminalNotification', ...args]) + }) + return calls + } + + it('does not consume bytes: a byte BEL produces no unread or notification', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'bell' }]) + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('fires the cache timer and completion from working→idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + await dispatchFacts([ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'agent-idle', title: IDLE_TITLE } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('clears state without completion attention for stale-derived idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + // Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a + // merely-paused agent must not earn a task-complete notification. + await dispatchFacts([ + { + kind: 'title', + normalizedTitle: 'Build feature', + rawTitle: 'Build feature', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true } + ]) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith( + TAB_ID, + PANE_ID, + 'Build feature' + ) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + dispose() + }) + + it('replay batches restore the title only — attention facts never replay', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts( + [ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'bell' }, + { kind: 'agent-idle', title: IDLE_TITLE } + ], + { replay: true } + ) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => { + // Why: with the hidden-delivery gate on (default), parked PTY bytes are + // dropped in main — the fact is the only 2031 signal, and the byte + // sidecar must NOT exist (its registration would re-enable delivery). + enableMainAuthority() + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?2031h') + expect(sendInput).not.toHaveBeenCalled() + + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: pr-link facts arrive on the channel; byte-scanning here too + // would observe every link twice. + emit('PR: https://github.com/orca-dev/orca/pull/42\r\n') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + dispose() + }) + + it('marks the PTY hidden for delivery on start and clears it on dispose', async () => { + enableMainAuthority() + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose } = await startWatcher() + + expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) + + dispose() + // Why: the unhide must land before reveal re-registers pane handlers — + // the watcher registry disposes watchers before the remount effect runs. + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) + + it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as MockStoreState['settings'] + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose, sendInput } = await startWatcher() + + // Gate off — bytes keep flowing, so the split-chunk byte scan answers. + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a 2031-subscribe fact must not double-fire the reply in byte + // mode — exactly one responder owns the answer at any time. + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + + expect(setHiddenRendererPty).not.toHaveBeenCalled() + dispose() + }) + + it('observes PR links from pr-link facts with worktree attribution', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + const link = { + url: 'https://github.com/orca-dev/orca/pull/421', + slug: { owner: 'orca-dev', repo: 'orca' }, + number: 421 + } + await dispatchFacts([{ kind: 'pr-link', link }]) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + link + ) + dispose() + }) + + it('dispose unregisters the fact consumer and clears a written title slot', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }]) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + + await dispatchFacts([{ kind: 'bell' }]) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // The key migration-safety check: the same bytes produce the identical + // ordered store outcome whether the watcher parses them directly (kill + // switch off) or consumes main-derived facts over the channel. + it('produces identical store outcomes via the channel as the byte parser did', async () => { + const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`] + + // Pass 1: legacy byte-parser mode. + const byteModeCalls = recordPolicyOutcomes() + { + const { dispose } = await startWatcher() + for (const chunk of fixtureChunks) { + emit(chunk) + flushSideEffects() + } + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + // Pass 2: fresh modules/store, authority on, facts derived by the + // shared main tracker from the same bytes. + vi.resetModules() + mockStoreState = createMockStoreState() + dispatchTerminalNotification.mockReset() + const factModeCalls = recordPolicyOutcomes() + { + enableMainAuthority() + const { dispose } = await startWatcher() + await emitViaMainTrackerFacts(fixtureChunks) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + expect(byteModeCalls.length).toBeGreaterThan(0) + expect(factModeCalls).toEqual(byteModeCalls) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts new file mode 100644 index 00000000000..d5bd9eec579 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -0,0 +1,347 @@ +/** + * Parked terminal side-effect watcher. + * + * Why: parking unmounts the TerminalPane subtree, which tears down the pane's + * side-effect consumers — the parked tab's only source of bell, title, + * agent-completion, and PR-link policy. (Losing them is the gap that sank the + * first parking attempt.) Under main side-effect authority the watcher is + * purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the + * kill switch off it registers the legacy byte parsers on the dispatcher + * sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate: + * gate ON answers from main's '2031-subscribe' fact (no parked bytes exist), + * gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts). + * Either way the reply is sent from the renderer — query authority never + * moves to main. See docs/reference/terminal-hidden-view-parking.md and + * docs/reference/terminal-side-effect-authority.md. + */ +import { isClaudeAgent } from '../../../../shared/agent-detection' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode +} from '../../../../shared/terminal-color-scheme-protocol' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' +import { createPtyOutputProcessor } from './pty-transport' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { dispatchTerminalNotification } from './use-notification-dispatch' +import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' + +// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts. +// The parked path must keep the live path's BEL-vs-completion race window so +// notification behavior is identical whether a tab is parked or mounted. +const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS + +type StoreState = ReturnType + +function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { + return isAgentTaskCompleteOsNotificationEnabledFromState(state) +} + +function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { + return isAgentTaskCompleteTrackingEnabledFromState(state) +} + +export type ParkedTerminalByteWatcherOptions = { + ptyId: string + tabId: string + worktreeId: string + /** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey + * used for cache-timer, unread, and notification attribution. */ + leafId: string + /** PaneManager pane id the unmounted pane used. Runtime pane titles are + * keyed by it, so the watcher must write the slot the live path wrote — + * a different id would leave a stale (possibly "working") title behind. */ + paneId: number + /** Whether this PTY's pane was the tab's active split pane. Mirrors the + * live path, where only the focused split drives the tab title. */ + drivesTabTitle?: boolean + /** The pane's last known runtime title at park time. Seeds the agent + * tracker so an agent that was working when the pane unmounted still + * fires its completion when it goes idle while parked. */ + initialTitle?: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +const parkedWatcherDisposersByPtyId = new Map void>() + +export function startParkedTerminalByteWatcher( + options: ParkedTerminalByteWatcherOptions +): () => void { + const { ptyId, tabId, worktreeId, paneId, sendInput } = options + const drivesTabTitle = options.drivesTabTitle ?? true + const paneKey = makePaneKey(tabId, options.leafId) + + // Why: one watcher per PTY. A stale watcher from a previous park cycle would + // double-fire bell/completion side effects for the same bytes. + parkedWatcherDisposersByPtyId.get(ptyId)?.() + + let disposed = false + let pendingBellNotification = false + // Why: a watcher-written runtime title (especially into a negative fallback + // slot) has no live pane to overwrite it after reveal; a stale 'working' + // entry would pin worktree status forever. Track writes so dispose can + // clear exactly the slot this watcher touched. + let wroteRuntimeTitleSlot = false + let bellNotificationTimer: ReturnType | null = null + let agentTaskCompleteTimer: ReturnType | null = null + + const clearBellNotificationTimer = (): void => { + if (bellNotificationTimer !== null) { + clearTimeout(bellNotificationTimer) + bellNotificationTimer = null + } + } + + const clearAgentTaskCompleteTimer = (): void => { + if (agentTaskCompleteTimer !== null) { + clearTimeout(agentTaskCompleteTimer) + agentTaskCompleteTimer = null + } + } + + // Why: like the live path, a BEL OS notification only yields when the + // pending completion would itself produce an OS notification. + const hasPendingAgentTaskCompleteNotification = (): boolean => + agentTaskCompleteTimer !== null && + isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + + const scheduleTerminalBellNotification = (): void => { + if (bellNotificationTimer !== null) { + return + } + bellNotificationTimer = setTimeout(() => { + bellNotificationTimer = null + if (disposed) { + pendingBellNotification = false + return + } + if (hasPendingAgentTaskCompleteNotification()) { + return + } + pendingBellNotification = false + dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey }) + }, PARKED_NOTIFICATION_GRACE_MS) + } + + // Why: one policy block for both consumption modes — byte parsing (kill + // switch off) and pty:sideEffect facts (main authority on). The semantics + // must be identical or flipping the switch changes notification behavior. + const sideEffectCallbacks = { + onTitleChange: (title: string): void => { + const state = useAppStore.getState() + wroteRuntimeTitleSlot = true + state.setRuntimePaneTitle(tabId, paneId, title) + if (drivesTabTitle) { + state.updateTabTitle(tabId, title) + } + }, + onBell: (): void => { + const state = useAppStore.getState() + state.markWorktreeUnread(worktreeId) + state.markTerminalTabUnread(tabId) + if (state.settings?.experimentalTerminalAttention === true) { + state.markTerminalPaneUnread(paneKey) + } + // Why: agent CLIs often emit BEL in the same completion burst as their + // working→idle title change. Delay only the OS notification so the + // richer agent-task-complete notification can win (live-path parity). + pendingBellNotification = true + if (!hasPendingAgentTaskCompleteNotification()) { + scheduleTerminalBellNotification() + } + }, + onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: stale-derived idles come from main's unthrottled 3s timer, not + // observed bytes — clear session state, never schedule the completion + // notification a merely-paused agent did not earn (live-path parity). + if (meta?.staleWorkingTitleClear) { + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + return + } + const state = useAppStore.getState() + // Why: mirrors pty-connection — null settings means "not hydrated yet"; + // a spurious timestamp is harmless while a dropped one loses the timer. + if ( + isClaudeAgent(title) && + (state.settings === null || state.settings.promptCacheTimerEnabled) + ) { + state.setCacheTimerStartedAt(paneKey, Date.now()) + } + if (!isAgentTaskCompleteTrackingEnabled(state)) { + return + } + clearAgentTaskCompleteTimer() + agentTaskCompleteTimer = setTimeout(() => { + agentTaskCompleteTimer = null + if (disposed) { + return + } + // Why: the completion supersedes a concurrent BEL so each completion + // burst yields exactly one OS notification, same as the live path. + pendingBellNotification = false + clearBellNotificationTimer() + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + ? {} + : { suppressOsNotification: true }) + }) + }, PARKED_NOTIFICATION_GRACE_MS) + }, + onAgentBecameWorking: (): void => { + // Why: a new API call refreshes the prompt-cache TTL, so clear any + // running countdown; it restarts when the agent next becomes idle. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + clearAgentTaskCompleteTimer() + if (pendingBellNotification) { + scheduleTerminalBellNotification() + } + }, + onAgentExited: (): void => { + // Why: title reverting to a plain shell means the agent session ended; + // a stale countdown must not survive in the sidebar while parked. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + } + } + + // Why: parking eligibility excludes remote-runtime and SSH PTYs, so every + // watched PTY's bytes transit local main — when the authority switch is on, + // the watcher must NOT register byte parsers (the fact consumer below is + // the single policy consumer; double registration would double-fire bells). + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) + // Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all — + // facts carry side effects and the reveal remount restores from the model + // snapshot. Decided once at watcher start: it picks which 2031 responder + // (byte sidecar vs fact reply) exists, so it must never flip per chunk. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && + isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings) + + const sendMode2031Reply = (): void => { + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + } + + // Why (byte-parser mode only): reuse the transport's output processor so + // the parked path keeps the exact live-path parsing semantics — all-titles + // ordering, normalization, the cursor-agent native-title drop, the + // OSC-aware stateful bell detector, and the working/idle agent tracker. + // initialAgentTitle: an agent already working at park time must still + // produce a working→idle transition; main's continuous tracker covers this + // in fact-consumer mode. + const processor = mainSideEffectAuthority + ? null + : createPtyOutputProcessor({ + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + ...sideEffectCallbacks + }) + // Why (byte-parser mode only): with main authority, pr-link facts arrive on + // the channel below; byte-scanning too would observe every link twice. + const observeTerminalGitHubPRLink = mainSideEffectAuthority + ? null + : createTerminalGitHubPRLinkDetector() + const unregisterFactConsumer = mainSideEffectAuthority + ? registerTerminalSideEffectFactConsumer({ + ptyId, + // Why: no title snapshot on park — the pane's runtime title slot is + // already current at park time, exactly like the byte-parser mode. + callbacks: { + ...sideEffectCallbacks, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + // Why (gate mode only): bytes never arrive while gated, so the 2031 + // subscribe arrives as a main-tracker fact instead of a byte scan. + // The reply is still sent from here — query authority stays with + // the view/watcher (model/view contract invariant 6). + ...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {}) + } + }) + : null + + // Why: no xterm exists while parked, so nothing answers a DECSET 2031 + // subscription. With the hidden-delivery gate OFF the byte responder is the + // parked path's only byte consumer under main authority. With the gate ON it + // must NOT register: its subscribeToPtyData sidecar doubles as a + // delivery-interest signal that would force-feed bytes to the gated PTY — + // the fact callback above replaces the byte scan. + const stopMode2031Responder = hiddenDeliveryGateActive + ? null + : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + + // Why: parked tabs are the canonical hidden view — mark the PTY gated so + // main stops renderer byte delivery; dispose clears the bit before the + // reveal remount re-registers pane handlers (existing dispose ordering). + const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive + ? acquireHiddenRendererPtyDeliveryClaim(ptyId) + : null + + // Why (byte-parser mode only): with main authority the watcher consumes + // pty:sideEffect facts exclusively and registers NO byte parsers here — + // title/bell/agent parsing and the PR-link scan would double-fire policy. + const unsubscribeByteParsers = + processor === null + ? null + : subscribeToPtyData(ptyId, (data) => { + // Why: empty pane callbacks — the watcher wants only the parser + // side effects; there is no xterm to deliver bytes to. + processor.processData(data, {}) + if (observeTerminalGitHubPRLink) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } + } + }) + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + // Why: unhide BEFORE the reveal remount registers pane handlers — main + // resumes delivery and (if bytes were dropped) emits the restore marker + // the remounted pane's restore machinery consumes. + releaseHiddenDeliveryClaim?.() + stopMode2031Responder?.() + unsubscribeByteParsers?.() + unregisterFactConsumer?.() + // Why: cancels the deferred side-effect drain, stale-title timer, and + // tracker/bell-detector state so the watcher cannot fire after the + // revealed pane's live parsers take over. + processor?.clearAccumulatedState() + clearBellNotificationTimer() + clearAgentTaskCompleteTimer() + pendingBellNotification = false + // Why: the store merge never deletes title slots, so a watcher-written + // entry would strand after reveal (the revealing pane re-registers under + // its own pane id) and could pin worktree status 'working'. The revealed + // pane repopulates its slot via its own title flow. + if (wroteRuntimeTitleSlot) { + wroteRuntimeTitleSlot = false + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) { + parkedWatcherDisposersByPtyId.delete(ptyId) + } + } + parkedWatcherDisposersByPtyId.set(ptyId, dispose) + return dispose +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts new file mode 100644 index 00000000000..da804026de6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -0,0 +1,50 @@ +/** + * DECSET 2031 color-scheme responder for parked terminals (byte-scan mode). + * + * Why a dedicated byte sidecar: no xterm exists while a tab is parked, so + * nothing answers a TUI's mode-2031 theme subscription. Query authority stays + * with the view/watcher (model/view contract invariant 6), so this reply can + * never move to main. Phase 4: this subscribeToPtyData registration doubles + * as a delivery-interest signal, so it is only used while the hidden-delivery + * gate is OFF — gated parked PTYs answer from the main tracker's + * '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts). + * + * Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes + * answer 2031 from xterm once the background queue drains, but a PARKED tab + * has no xterm in any switch-off mode, and the '2031-subscribe' fact is only + * consumed while the gate is ON — this sidecar stays the only answerer here. + */ +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../../../shared/terminal-color-scheme-protocol' +import { useAppStore } from '@/store' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' + +export type ParkedTerminalMode2031ResponderOptions = { + ptyId: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +export function startParkedTerminalMode2031Responder( + options: ParkedTerminalMode2031ResponderOptions +): () => void { + const { ptyId, sendInput } = options + // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan + // carries a bounded tail between chunks so split sequences still match. + let scanTail = '' + return subscribeToPtyData(ptyId, (data) => { + const scan = scanMode2031Sequences(scanTail, data) + scanTail = scan.tail + if (!scan.subscribe) { + return + } + // Why: reply with the resolved theme so TUIs that subscribe while parked + // still learn it before the pane is ever revealed. + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 70abd4fae3f..8e151c6ab5c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -76,4 +76,10 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Records a DECSET 2031 subscription answered from main's + * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write + * (paneMode2031 + last replied theme) so later theme flips push CSI 997. + * The reply itself is sent by the fact handler — query authority stays + * with the view (model/view contract invariant 6). */ + recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void } 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 72aac62a8e8..f4e80152546 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -149,6 +149,8 @@ type StoreState = { terminalWindowsShell?: string terminalWindowsWslDistro?: string | null localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -195,7 +197,10 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void + onData?: ( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } + ) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -379,7 +384,8 @@ function createPane(paneId: number) { type: 'normal' as const, viewportY: 0, baseY: 0, - cursorY: 0 + cursorY: 0, + cursorX: 0 } return { id: paneId, @@ -397,6 +403,7 @@ function createPane(paneId: number) { sendFocusMode: false }, options: { + scrollback: 5_000, ignoreBracketedPasteMode: false, theme: { foreground: '#eeeeee', @@ -738,7 +745,14 @@ describe('connectPanePty', () => { projects: [], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, + // Why: terminalMainSideEffectAuthority false pins the legacy renderer + // byte-parser wiring this suite asserts on (onTitleChange/onBell on the + // transport). The authority-on fact-consumer mode has its own tests. + settings: { + promptCacheTimerEnabled: true, + experimentalTerminalAttention: true, + terminalMainSideEffectAuthority: false + }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -811,6 +825,8 @@ describe('connectPanePty', () => { hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -2367,6 +2383,32 @@ describe('connectPanePty', () => { expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) }) + it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { + // Why (regression, ghost blank pane): the keep above is a visible-failure + // UX. A hidden pane's bytes are withheld by the hidden-delivery gate, so + // "no output" proves nothing there — keeping it strands a binding-less + // pane that remounts as a permanently blank ghost on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + isVisibleRef: { current: false }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { // Why (regression): a PR worktree can ship an .envrc whose direnv command // fails, so the only terminal's login shell exits non-zero immediately. The @@ -7316,18 +7358,89 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps non-visible local PTY bytes on the live xterm path for release', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + // Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill + // switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs, + // synchronized frames, embedded queries — rides the bounded background + // scheduler queue and parses in xterm; nothing is content-scanned per chunk. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + 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 manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() try { + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } + + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void) + | null + } = { current: null } transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null @@ -7335,101 +7448,721 @@ describe('connectPanePty', () => { } ) transportFactoryQueue.push(transport) - const pane = createPane(1) const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + pane.terminal.options.scrollback = 50_000 + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // Why: with the skip grammar gone, gated drops latch the restore via + // main's out-of-band marker, not a renderer-side content scan. + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 50_000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { + enableMainAuthority() const deps = createDeps({ - isVisibleRef: { current: false } + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like + // any hidden pane and the main responder answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact stays the sole 2031 responder for gate-managed PTYs. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte + // path latches it on the first hidden chunk, like the hidden mark. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate the transport's spawn completion so the pane registers its + // side-effect fact consumer (the mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: no pty:data has flowed, so no hidden mark was sent — the fact + // can outrun the mark (codex post-startup-window race) and must still + // reply: ownership is structural, never mark-dependent. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a visible gated pane still answers via the fact — the lifecycle + // suppresses the xterm CSI reply for gate-managed panes, so this stays + // the only reply for the new subscribe. + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + }) + + it('registers the fact-answered 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: without the registry write, applyTerminalAppearance's + // maybePushMode2031Flip never pushes CSI 997 after a theme change and + // the revealed TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + }) - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { binding } = await connectHiddenPane(deps) + const bindingWithPredicate = binding as typeof binding & { + isHiddenDeliveryGateManagedPty: () => boolean + } + expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + }) - for (const fn of pendingTimeouts) { - fn() + it('declares hidden-at-spawn on connect for hidden panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: waiting for the first dataCallback sync left a spawn-time query + // window where neither side replied (the spawn-time DA1 loss). The flag + // lets main mark the PTY hidden before its first byte. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('declares hidden-at-spawn for hidden codex panes too', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Why: the 10s codex startup window is deleted — codex spawns are + // main-owned from byte zero, with the model responder answering their + // startup probes (including ConPTY's blocking DA1; the main-side pin is + // pty.test.ts 'answers DA1 from the model on the first chunk of a + // hidden-at-spawn PTY'). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback, binding } = await connectHiddenPane(deps) + // Why: the lifecycle's xterm CSI observer consults this predicate — + // kill switch off must keep the legacy xterm reply path. + expect( + ( + binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } + ).isHiddenDeliveryGateManagedPty() + ).toBe(false) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact + // must not produce a second reply for the same subscribe. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: a chunk that is purely OSC 9999 reaches the data callback as '' + // (transport stripping) — only the out-of-band pty:modelRestoreNeeded + // channel may trigger a snapshot restore, or visible panes would be + // spuriously cleared and repainted mid-session. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'post-flood repaint\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + try { + // Why (rc.7.perf feedback loop): a second drop marker while the first + // snapshot is still serializing on a VISIBLE pane is this pane's own + // restore backpressure. Re-fetching per marker kept the loop alive for + // the whole flood — the marker must NOT schedule another fetch. + vi.useFakeTimers() + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 80 + }) + firstSnapshot.resolve({ data: 'first snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Flood quiet: the suppression window elapses and exactly ONE deferred + // repaint fetches a fresh snapshot to heal the dropped gap. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + describe('foreground flood restore feedback loop (rc.7.perf)', () => { + function writtenFloodData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') } - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) + async function startInFlightRestore(): Promise<{ + pane: ReturnType + transport: MockTransport + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + getMainBufferSnapshot: ReturnType + resolveFirstSnapshot: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'repaint snapshot\r\n', cols: 100, rows: 30, seq: 5_000_000 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + return { + pane, + transport, + dataCallback, + getMainBufferSnapshot, + resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) + } + } - it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - 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) + it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { + const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = + await startInFlightRestore() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } + // Flood while the snapshot is in flight: overflows the 512KB restore + // queue — the live stream is outrunning snapshot fetch+replay. + dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) + dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) + + try { + vi.useFakeTimers() + resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + // Cut 1: the overflow abandons the restore instead of re-fetching. + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Cut 2: drop sentinels and seq-gap chunks during the flood window + // must not re-arm restores — the post-gap bytes write through. + dataCallback('', { droppedOutput: true }) + dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') + + // After the flood goes quiet: exactly ONE deferred repaint. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(5_000) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('salvages stateful queries out of an overflowing restore queue', async () => { + const { pane, transport, dataCallback } = await startInFlightRestore() + + // Queue 400KB, then a chunk that overflows the cap and carries a DSR + // probe. The content is discarded (snapshot owns it) but the probe's + // reply is SYNTHESIZED directly — replaying it into xterm would race + // the restore's discard and the replay guard's auto-reply swallow. + dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) + dataCallback(`${'b'.repeat(200 * 1024)}\x1b[6n`, { + seq: 600 * 1024 + 4, + rawLength: 200 * 1024 + 4 + }) + await flushAsyncTicks(8) + + const replies = transport.sendInput.mock.calls.map((call) => String(call[0])) + // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply + expect(replies.some((reply) => /^\u001b\[\d+;\d+R$/.test(reply))).toBe(true) + const written = writtenFloodData(pane) + expect(written).not.toContain('aaaa') + expect(written).not.toContain('bbbb') + }) + + it('keeps the hidden-pane drop sentinel arming a reveal restore (gate unchanged)', async () => { + enableMainAuthority() + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'hidden reveal snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + // Hidden pane: the sentinel latches restore-needed but must not fetch. + dataCallback('', { droppedOutput: true }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + // Reveal: the latched restore fetches exactly one snapshot. + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('hidden reveal snapshot') + }) }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot } + } - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n' - capturedDataCallback.current?.(hiddenTuiChunk) + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() - it('keeps split hidden synchronized output frames on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): renderer-side OSC stripping makes + // the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point (start seq 87 > + // expected 64) means main trimmed bytes after the one-shot overflow + // marker was consumed — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin (terminal-hidden-tui-visual-restore "keeps newer live + // output correct"): main's snapshot seq is a cumulative PTY counter + // (shell init + prompt echo + hidden frame), while a synthetic live + // chunk meters only its own frames — far below the baseline. With an + // empty pending queue main can never re-deliver seqs at or below the + // snapshot, so the chunk must write, never silently drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty + // (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's + // counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, + // so this is a foreign seq domain — written, never silently dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } }) it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { @@ -7986,7 +8719,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('side-channel answers mode 2031 when hidden Codex output is snapshot-backed', async () => { + it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -8002,17 +8735,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } const binding = connectPanePty( pane as never, manager as never, - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never + createDeps({ isVisibleRef: { current: false } }) as never ) await flushAsyncTicks(6) @@ -8021,10 +8747,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') - expect(paneMode2031Ref.current.get(1)).toBe(true) - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() } @@ -8583,6 +9307,53 @@ describe('connectPanePty', () => { } }) + it('repaints from the main-owned snapshot when main drops pending output at the cap', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { droppedOutput?: 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 + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'healed from snapshot\r\n', + cols: 100, + rows: 30 + }) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef: { current: true } }) as never + ) + try { + await flushAsyncTicks(6) + getMainBufferSnapshot.mockClear() + + // Main hit the per-PTY pending cap while the renderer was starved and + // sent the droppedOutput sentinel: the stream has a gap, so the pane + // must repaint from the authoritative main-owned buffer. + capturedDataCallback.current?.('', { droppedOutput: true }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('healed from snapshot'), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + it('keeps split stateful Codex queries live after becoming visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8868,12 +9639,21 @@ describe('connectPanePty', () => { }) it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { visibilityState: 'visible', addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -9169,17 +9949,21 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => { + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null } = { current: null } + // Why: with the skip grammar gone, the model restore for remote-runtime + // PTYs is latched by background-queue overflow, not per-chunk scanning. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'remote snapshot\r\n', + data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, - seq: 40, + seq: hidden.length + live.length, source: 'headless' }) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -9197,26 +9981,27 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' - const live = 'visible remote output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) ;(deps.isVisibleRef as { current: boolean }).current = true capturedDataCallback.current?.(live, { - seq: 40 + live.length, + seq: hidden.length + live.length, rawLength: live.length }) await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote snapshot with hidden remote output'), + expect.any(Function) + ) disposable.dispose() }) - it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => { + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') let disposable: { dispose: () => void } | null = null @@ -9250,7 +10035,9 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflowing the background queue is what latches the model + // restore now — the per-chunk skip grammar is gone. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9268,9 +10055,12 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 30)) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('inactive snapshot'), + expect.any(Function) + ) } finally { disposable?.dispose() resetHiddenOutputRestoreSchedulerForTests() @@ -9311,7 +10101,8 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9335,7 +10126,7 @@ describe('connectPanePty', () => { } }) - it('does not retry remote snapshots for ordinary hidden runtime output', async () => { + it('retries null remote snapshots for overflowed hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -9360,7 +10151,8 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const firstLive = 'first visible output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9371,7 +10163,7 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('Orca skipped hidden terminal output'), expect.any(Function) @@ -9384,11 +10176,17 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 80)) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote recovered snapshot'), + expect.any(Function) + ) disposable.dispose() }) + // Why: pins the entire switch-off hidden fallback chain — hidden bytes ride + // the background queue, the 2MB lossy cap drops the backlog and latches the + // restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -9438,7 +10236,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { + it('rebuilds normal and alternate buffers from an authoritative alternate snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -9459,7 +10257,8 @@ describe('connectPanePty', () => { cols: 100, rows: 30, seq: hidden.length + live.length, - alternateScreen: true + alternateScreen: true, + scrollbackAnsi: 'preserved-shell-history\r\n' }) const pane = createPane(1) @@ -9481,23 +10280,25 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - // Why: the destructive clear wipes xterm's scrollback. Alt-screen TUIs keep - // their history in xterm, so restoring one must NOT emit the clear. - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[2J\x1b[3J\x1b[H', + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049l\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', + 'preserved-shell-history\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[0m\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( + expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( + writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + ) + expect(writes.indexOf('\x1b[0m\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)) @@ -10154,14 +10955,23 @@ describe('connectPanePty', () => { it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { let visibilityState: DocumentVisibilityState = 'visible' - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { get visibilityState() { return visibilityState }, addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -10588,6 +11398,73 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => 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 hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12439,6 +13316,452 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on (the default), local/SSH transports must not + // register title/bell/agent byte parsers; the pane's policy callbacks are + // registered as the PTY's single pty:sideEffect fact consumer instead. + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // The lifecycle callbacks stay on the transport — only side-effect + // parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + 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-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: the done fact is a hint — the settle timer stays in the pane + // policy because it must consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + 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-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: the store has no settings yet, but the user persisted + // the kill switch off. The pane must register byte parsers, not a fact + // consumer — and hydration must not produce a second consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + + // Hydration lands with the switch still off: byte parsing stays the + // single consumer — one BEL marks unread exactly once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + }) + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 94fd3774fa4..ae7dbfbcf1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -13,6 +13,17 @@ import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-ki import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' +import { + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, + containsCsiRendererQuery, + containsStatefulRendererQuery, + extractHiddenStartupRendererQueryData, + findCsiFinalByteIndex, + isStatefulRendererReplyCsiQuery, + isStatelessRendererReplyCsiQuery +} from '../../../../shared/terminal-reply-query-extraction' +import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' @@ -37,6 +48,12 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isDocumentVisibilityProvenStale, + registerStaleDocumentVisibilityRecovery +} from './stale-document-visibility' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' import { nativeWindowsRewriteNeedsFollowupRenderRefresh, terminalOutputPrefersRenderRefresh, @@ -81,7 +98,10 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' -import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' +import { + isLocalNativeWindowsConpty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { captureTerminalWriteScrollIntent, @@ -133,21 +153,30 @@ import { import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' -import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, + DEFAULT_DA1_RESPONSE, createTerminalPixelSizeQueryResponder, installTerminalCapabilityReplyHandlers, sendTerminalOscColorQueryReplies } from './terminal-capability-replies' +import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' +import { + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' import { cancelScheduledHiddenOutputRestore, scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner, @@ -185,14 +214,23 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, + canDispatchAgentNotificationAfterGrace, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 -const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 -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 @@ -202,11 +240,22 @@ const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 export const STARTUP_CWD_FALLBACK_NOTICE = '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 -const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 +// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the +// restore pipeline is its own bottleneck — each synchronous snapshot replay +// starves ACK processing, main pins at the in-flight cap, drops at the +// pending cap, and every drop marker re-armed another restore until the +// flood ended. Backpressure evidence opens this suppression window: drop +// markers inside it must not re-arm restores; live bytes write through and +// ONE deferred repaint (when the window closes) heals the visual gap. +const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000 +// Backstop for the same loop: a single in-flight restore task may re-iterate +// (fresh-snapshot marks, unmappable slices) only this many times before it +// abandons and lets live bytes flow. +const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' @@ -357,6 +406,9 @@ type E2eTerminalHiddenSnapshotOverride = { const e2eTerminalHiddenSnapshotOverrides = new Map() +// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes +// either never reach the renderer (delivery gate) or ride the background +// scheduler queue. Only the mode-2031 fact-reply counter still has a producer. type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number @@ -516,157 +568,6 @@ function containsHiddenStartupRendererQuery(data: string): boolean { return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') } -const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 - -function extractHiddenStartupRendererQueryData( - data: string, - pending: string -): { - statelessQueryData: string - statefulQueryData: string - oscColorQueryData: string - pending: string -} { - const input = pending + data - let statelessQueryData = '' - let statefulQueryData = '' - let oscColorQueryData = '' - let offset = 0 - - while (offset < input.length) { - const candidateIndex = input.indexOf('\x1b', offset) - if (candidateIndex === -1) { - break - } - if (candidateIndex + 1 >= input.length) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - if (input.startsWith('\x1b[', candidateIndex)) { - const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) - if (finalByteIndex === -1) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - const sequence = input.slice(candidateIndex, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence)) { - statelessQueryData += sequence - } else if (isStatefulRendererReplyCsiQuery(sequence)) { - statefulQueryData += sequence - } - offset = finalByteIndex + 1 - continue - } - - if (input.startsWith('\x1b]', candidateIndex)) { - const query = parseTerminalOscColorQuery(input, candidateIndex) - if (query.kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - if (query.kind === 'none') { - offset = candidateIndex + 2 - continue - } - oscColorQueryData += input.slice(candidateIndex, query.endIndex) - offset = query.endIndex - continue - } - - if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - - { - offset = candidateIndex + 1 - continue - } - } - - return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } -} - -function containsCsiRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function containsStatefulRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function findCsiFinalByteIndex(data: string, offset: number): number { - for (let index = offset; index < data.length; index++) { - const code = data.charCodeAt(index) - if (code >= 0x40 && code <= 0x7e) { - return index - } - } - return -1 -} - -function isStatelessRendererReplyCsiQuery(sequence: string): boolean { - if (sequence.endsWith('c')) { - return true - } - return ( - sequence === '\x1b[5n' || - sequence === '\x1b[>q' || - sequence === '\x1b[14t' || - sequence === '\x1b[16t' - ) -} - -function isStatefulRendererReplyCsiQuery(sequence: string): boolean { - return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) -} - let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -691,40 +592,19 @@ type PanePtyBinding = IDisposable & { sampleForegroundAgentOnFocus: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void + /** True when the hidden-delivery gate structurally manages the pane's + * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to + * stay silent — main's '2031-subscribe' fact is the sole responder for + * gate-managed PTYs. */ + isHiddenDeliveryGateManagedPty: () => boolean } function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState()) -} - -function isAgentTaskCompleteNotificationEnabledFromState( - state: ReturnType -): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabledFromState( - state: ReturnType -): boolean { - return state.settings?.experimentalTerminalAttention === true + return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) } function isAgentTaskCompleteTrackingEnabled(): boolean { - const state = useAppStore.getState() - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) -} - -function isAgentTaskCompleteTrackingEnabledFromState( - state: ReturnType -): boolean { - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) + return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) } const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() @@ -734,7 +614,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null function getAgentTaskCompleteTrackingSettingsSnapshot( state: ReturnType ): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}` + return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` } function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { @@ -768,27 +648,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => } } -function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { - return Boolean( - entry && - Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && - (entry.lastAssistantMessage || entry.toolName || entry.toolInput) - ) -} - -function canDispatchAgentNotificationAfterGrace( - entry: AgentStatusEntry | undefined, - options: { allowDoneDetailAfterGrace?: boolean } = {} -): boolean { - // Why: hook-backed goal/mission loops can report `done` between milestones. - // User-input states may notify as soon as detail arrives, but `done` waits - // for the max quiet window so resumed work can cancel the pending banner. - return ( - hasAgentNotificationDetail(entry) && - (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) - ) -} - function recordPtyConnectDiagnostic(message: string): void { if (!e2eConfig.exposeStore) { return @@ -936,7 +795,13 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { // Why: Electron can keep visible panes mounted while the whole app is // backgrounded. Treat hidden documents like background tabs so Chromium // timer throttling cannot pin terminal writes on the renderer foreground path. - return document.visibilityState === 'visible' + if (document.visibilityState === 'visible') { + return true + } + // Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after + // display sleep; proven-stale means real user input contradicted it, so the + // hidden-delivery gate must not keep dropping a watched pane's bytes. + return isDocumentVisibilityProvenStale() } function containsSynchronizedOutputStart(data: string): boolean { @@ -1074,6 +939,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} @@ -1096,6 +962,11 @@ export function connectPanePty( // window still drains on the fast path instead of the 1s coalesce fallback. let synchronizedForegroundFrameInteractive = false let suppressSnapshotReplayPtyResize = false + // Why: hidden-delivery gate sync is wired up alongside the deferred PTY + // output plumbing inside the connect frame; lifecycle hooks (visibility + // flips, exit, dispose) run before/after it exists, so start with no-ops. + let syncHiddenRendererPtyDelivery: () => void = () => {} + let releaseHiddenRendererPtyDelivery: () => void = () => {} // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -1863,6 +1734,40 @@ export function connectPanePty( hasKnownAgentIdentity: paneHasKnownAgentIdentity, onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell }) + // Why: one command-finished policy whether the signal arrives as bytes + // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — + // routing both through this handler keeps the drop/interrupt semantics + // identical across authority modes. + const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + clearCommandInferredPaneAgentAfterPtySideEffects() + paneForegroundAgentTracker.onCommandFinished() + // 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) + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const inferenceResult = flushPendingInterruptInference() + if (inferenceResult === true) { + // Why: OSC 133 D means the foreground shell command exited. If an + // interrupt was inferred first, drop only when the current interrupted + // row is still the same turn; otherwise a killed OpenCode CLI leaves a + // stale "interrupted" row even though the process is gone. + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } + // Why: OSC 133 D marks the foreground shell command exiting. Remove the + // row without retaining a done snapshot; this section represents a live + // agent process, and the shell prompt means that process is gone. + dropCommandFinishedStatusIfSameTurn(entry) + } const sampleVisiblePaneForegroundAgent = (): void => { if (!deps.isVisibleRef.current) { return @@ -1895,37 +1800,10 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), - onCommandFinished: () => { - clearCommandInferredPaneAgentAfterPtySideEffects() - paneForegroundAgentTracker.onCommandFinished() - // 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) - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - if (inferenceResult === true) { - // Why: OSC 133 D means the foreground shell command exited. If an - // interrupt was inferred first, drop only when the current interrupted - // row is still the same turn; otherwise a killed OpenCode CLI leaves a - // stale "interrupted" row even though the process is gone. - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - // Why: OSC 133 D marks the foreground shell command exiting. Remove the - // row without retaining a done snapshot; this section represents a live - // agent process, and the shell prompt means that process is gone. - dropCommandFinishedStatusIfSameTurn(entry) - } + onCommandFinished: handleCommandFinished }) + // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — + // it stays attached in every authority mode. commandLifecycle.attachXtermConsumer(pane.terminal) const onTerminalKeyDown = (event: KeyboardEvent): void => { if (isPlainEscapeKeyEvent(event)) { @@ -2001,6 +1879,47 @@ export function connectPanePty( // Why: bind time lets async liveness reconcile ignore a request started // before this PTY bound (newborn race). Null disables the guard (fail-safe). let activePanePtyBindingBoundAt: number | null = null + + // Why: with main side-effect authority on, the pane's title/bell/agent + // policy callbacks consume pty:sideEffect facts instead of transport byte + // parsers (which stay unregistered) — same policy code, single consumer. + // restoreTitleOnRegister replaces the eager-replay title restore: main's + // title-only snapshot carries the no-attention-replay rule. + let unregisterSideEffectFactConsumer: (() => void) | null = null + const registerSideEffectFactConsumerForPty = (ptyId: string): void => { + if (!mainSideEffectAuthority || disposed) { + return + } + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onCommandFinished: handleCommandFinished, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), + // Why: the Command Code settle policy stays here — the done settle + // timer must consult the live store row (which hook events and + // renderer seeds also write), so main only emits scrape facts. + onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + // Why: gated hidden panes never see the subscribe bytes; the fact + // replaces the byte scan (and the old post-latch subscribe drop). + ...(hiddenDeliveryGateActive + ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact } + : {}) + }, + restoreTitleOnRegister: true + }) + } + const dropSideEffectFactConsumer = (): void => { + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = null + } const clearPanePtyFitBinding = (): void => { // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. @@ -2191,6 +2110,10 @@ export function connectPanePty( } handledExitPtyId = ptyId agentCompletionCoordinator.dispose() + dropSideEffectFactConsumer() + // Why: main clears gate state on PTY exit too; this only resets the + // pane-local marker so a reused pane cannot skip re-marking a new PTY. + releaseHiddenRendererPtyDelivery() clearPanePtyFitBinding() // Why: the negotiating application died with its PTY; any replacement // session starts with kitty keyboard flags at zero. @@ -2273,6 +2196,7 @@ export function connectPanePty( return } if ( + deps.isVisibleRef.current && hadExistingPaneTransportAtConnect && !restoredPtyIdForTransport && !Number.isFinite(lastTerminalInputAt) && @@ -2280,6 +2204,10 @@ export function connectPanePty( ) { // Why: a freshly split pane can lose its newborn PTY during setup; keep // the split visible so the failed session does not immediately collapse. + // Hidden panes must close instead: the hidden-delivery gate withholds + // their bytes, so "no output" is meaningless there, and keeping one + // strands a binding-less pane the exit path never revisits — it remounts + // as a permanently blank ghost on reveal. focusSurvivingPtyPaneAfterKeptExit() return } @@ -2294,7 +2222,11 @@ export function connectPanePty( let hasConsideredInitialCacheTimerSeed = false let allowInitialIdleCacheSeed = false - const onTitleChange = (title: string, rawTitle: string): void => { + const onTitleChange = ( + title: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { // Why: one owner-aware decision drives the display label, the runtime/tab // title, task-completion tracking, and the renderer gate, so raw title text // can no longer disable GPU behind stronger owner evidence (#7428/#7447). @@ -2317,7 +2249,11 @@ export function connectPanePty( } manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle) - if (syncAgentTaskCompleteTrackingEnabled()) { + // Why: a stale-derived cleared title comes from main's unthrottled 3s + // timer, not agent output. It must update the visible title but never + // feed completion tracking — observeTitle would classify the cleared + // title as idle and mint a task-complete for a merely-paused agent. + if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) { agentCompletionCoordinator.observeTitle(decision.rawTitle) } // Why: only the focused pane should drive the tab title — otherwise two @@ -2436,11 +2372,6 @@ export function connectPanePty( }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS) } - const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({ - startupCommand: paneStartup?.command, - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => { if (!ptyId || isRemoteRuntimePtyId(ptyId)) { @@ -2448,7 +2379,7 @@ export function connectPanePty( // renderer-visibility registry, so reporting them here is misleading. return } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } const bindActivePanePty = ( ptyId: string, @@ -2467,6 +2398,8 @@ export function connectPanePty( // Why: record bind time on the spawn/attach chokepoint so the reconcile // guard knows this binding is newer than any pre-bind snapshot. activePanePtyBindingBoundAt = performance.now() + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) { @@ -2686,7 +2619,16 @@ export function connectPanePty( // findable after the OS banner is gone. Double-firing with a concurrent BEL // is handled by delaying the BEL OS notification below; main still keeps a // 5 s per-worktree dedupe as the final guard. - const onAgentBecameIdle = (title: string): void => { + const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not + // observed bytes — a merely-paused agent (>3s silent mid-task, window + // minimized) would otherwise mint a false task-complete OS notification + // that renderer timer throttling previously damped. Clear session-tied + // state only; never schedule completion attention from it. + if (meta?.staleWorkingTitleClear) { + deps.setCacheTimerStartedAt(cacheKey, null) + return + } // Why: only start the prompt-cache countdown for Claude agents — other // agents have different (or no) prompt-caching semantics and showing a // timer for them would be misleading. @@ -2775,7 +2717,10 @@ export function connectPanePty( userAgent: navigator.userAgent, connectionId, cwd: deps.cwd, - shellOverride, + // Why: main folds the global Windows shell into its spawn classification + // (pty.ts effectiveShellOverride); fold it here too so both sides treat + // a global-WSL default identically (terminal-query-authority.md ConPTY). + shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell), executionHostId }) if (isNativeWindowsConpty) { @@ -2819,6 +2764,37 @@ export function connectPanePty( }) : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + // Why: when main holds side-effect authority for this PTY's bytes, the + // transport must NOT register title/bell/agent byte parsers — the + // pty:sideEffect fact consumer below is the single policy consumer. + // Decided once at transport creation so a fact never has two consumers. + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: state.settings, + runtimeEnvironmentId + }) + // Why: Phase-4 hidden-delivery gate — only meaningful under main authority + // (renderer byte parsers need bytes otherwise). Decided once at pane + // creation: it picks the mode-2031 answer path (fact reply vs byte scan), + // which must have exactly one owner. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings) + // Why: structural per-PTY gate predicate (authority on + gate on + bytes + // transit local main, which implies snapshot-backed). Shared by the hidden + // mark sync and mode-2031 reply ownership so reply ownership can never + // disagree with what main may drop — and never depends on the racy hidden + // mark (a fact can outrun the pty:data task that sets it). + const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => + hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + // Why (byte-parser mode only): with main authority the Command Code scrape + // runs in main's per-PTY tracker and arrives as command-code facts; running + // the byte detector too would double-drive the seed/settle policy above. + const commandCodeOutputStatusDetector = mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + startupCommand: paneStartup?.command, + onWorking: seedCommandCodeOutputWorkingStatus, + onDone: scheduleCommandCodeOutputDoneStatus + }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY @@ -2880,12 +2856,16 @@ export function connectPanePty( ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, - onTitleChange, onPtySpawn, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, + ...(mainSideEffectAuthority + ? {} + : { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited + }), // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through @@ -2955,6 +2935,30 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 + // responder — visible, hidden, marked or not. Conditioning the reply on the + // hidden mark double-fired (mark set + bytes delivered live via interest → + // fact AND xterm both replied) or dropped the reply entirely (fact outran + // the pty:data task that set the mark). The xterm-side CSI reply and the + // skipped-byte scan are disabled for these panes (same structural + // predicate), so exactly one reply goes out. + const handleHiddenMode2031SubscribeFact = (): void => { + if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + const mode = resolveTerminalColorSchemeMode( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + // Why immediate: a mode-2031 query reply must beat the remote input debounce + // or it can miss the querying program's read window (#7329). + transport.sendInputImmediate(mode2031SequenceFor(mode)) + // Why: register the subscription exactly like the xterm CSI handler + // would — without the registry entry, later theme flips never push the + // CSI 997 update and the TUI keeps a stale theme after reveal. + deps.recordPaneMode2031Subscription?.(pane.id, mode) + recordHiddenMode2031Reply() + } deps.paneTransportsRef.current.set(pane.id, transport) const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ terminal: pane.terminal, @@ -3439,10 +3443,15 @@ export function connectPanePty( // into the seed when the user is mid-TUI; the read-fallback path // omits it because it wants the user's currently-visible content. const alt = pane.terminal.buffer.active.type === 'alternate' + // Why serializeWithAbsoluteCursor: SerializeAddon's relative + // cursor restore lands one column short when replay of a + // margin-filling final row leaves the target wrap-pending. const data = opts?.altScreenForcesZeroRows && alt - ? pane.serializeAddon.serialize({ scrollback: 0 }) - : pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows }) + ? serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { scrollback: 0 }) + : serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: opts?.scrollbackRows + }) return { data, cols: pane.terminal.cols, @@ -3873,6 +3882,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -4283,6 +4293,57 @@ export function connectPanePty( // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null let hiddenOutputRestoreGeneration = 0 + // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). + let hiddenOutputRestoreFloodSuppressedUntil = 0 + let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null + // Why: after a snapshot restore, main can still drain ACK-backlog chunks + // whose bytes the snapshot already covers — writing them unguarded + // duplicates visible output. Track the restored baseline seq (per PTY) + // and the expected next chunk start so dataCallback can drop/slice + // overlaps and detect seq gaps from main-side pending-cap trims whose + // one-shot marker was already consumed. + let restoredSnapshotBaselineSeq: number | null = null + let restoredSnapshotBaselinePtyId: string | null = null + let restoredSnapshotExpectedStartSeq: number | null = null + // Why: main samples its pending renderer-delivery queue with the snapshot. + // Chunks at or below this seq can never be backlog duplicates (delivery is + // once-and-in-order), so the dedupe window is (windowStart, baseline]. + let restoredSnapshotDeliveryWindowStartSeq: number | null = null + + function setRestoredSnapshotBaseline( + ptyId: string, + snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + ): void { + if (typeof snapshot.seq !== 'number') { + clearRestoredSnapshotBaseline() + return + } + const windowStartSeq = + typeof snapshot.pendingDeliveryStartSeq === 'number' + ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) + : null + if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { + // Why: main reported an empty undelivered backlog — no chunk at or + // below the snapshot seq can ever arrive again (delivery is once and + // in order) and a future pending-cap trim re-arms the out-of-band + // marker. Arming a baseline anyway would misread live chunks from a + // foreign seq domain (restarted counter / synthetic injection) as + // duplicates or trim gaps and silently drop genuinely-new output. + clearRestoredSnapshotBaseline() + return + } + restoredSnapshotBaselineSeq = snapshot.seq + restoredSnapshotBaselinePtyId = ptyId + restoredSnapshotExpectedStartSeq = snapshot.seq + restoredSnapshotDeliveryWindowStartSeq = windowStartSeq + } + + function clearRestoredSnapshotBaseline(): void { + restoredSnapshotBaselineSeq = null + restoredSnapshotBaselinePtyId = null + restoredSnapshotExpectedStartSeq = null + restoredSnapshotDeliveryWindowStartSeq = null + } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 let foregroundRewriteChunkEndedWithCarriageReturn = false @@ -4332,28 +4393,170 @@ export function connectPanePty( return transport.serializeBuffer(opts) } - function respondToSkippedMode2031Subscribe(data: string): void { - const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) - hiddenMode2031ScanTail = scan.tail - if (scan.finalState === 'unsubscribed') { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - if (scan.finalState !== 'subscribed') { + // Why: hidden/parked panes used to mark hidden only at the first + // dataCallback sync, leaving a spawn-time window where neither side + // answered queries (the spawn-time DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte — including + // codex spawns: the model responder answers their startup probes from + // byte zero now that the 10s renderer query window is gone. + // Remote-runtime PTYs are never gate-markable (no local main transit). + function shouldDeclareHiddenAtSpawn(): boolean { + return ( + hiddenDeliveryGateActive && + !runtimeEnvironmentId && + !disposed && + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + } + + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── + // Why: marks this pane's PTY hidden in main while no visible view needs + // its bytes; main then drops delivery after model ingestion and reveal + // restores from the snapshot. The marked id is tracked locally so PTY + // changes (reattach/restart) can never leave a stale id gated. + let hiddenDeliverySyncedPtyId: string | null = null + let releaseHiddenDeliveryClaim: (() => void) | null = null + let modelRestoreSubscribedPtyId: string | null = null + let unregisterModelRestoreNeeded: (() => void) | null = null + + function isHiddenOutputRestoreFloodSuppressed(): boolean { + return Date.now() < hiddenOutputRestoreFloodSuppressedUntil + } + + // True when a drop/gap signal on a visible pane is attributable to this + // pane's OWN restore backpressure (a restore is replaying right now, or + // one was just cut off for outrunning the stream). Such signals must not + // re-arm restores — that is the rc.7.perf feedback loop. + function isForegroundRestoreBackpressureContext(): boolean { + return ( + shouldWritePtyOutputForeground(deps.isVisibleRef.current) && + (hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) + ) + } + + function clearHiddenOutputRestoreFloodRepaintTimer(): void { + if (hiddenOutputRestoreFloodRepaintTimer === null) { return } - const settings = useAppStore.getState().settings - const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer - // mode 2031 out-of-band so TUIs still render the snapshot with the same - // theme-dependent styling they would have used in a visible pane. - deps.paneMode2031Ref.current.set(pane.id, true) - // Why: a query reply — send immediately so the remote input debounce - // cannot delay it past the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) - deps.paneLastThemeModeRef.current.set(pane.id, mode) - recordHiddenMode2031Reply() + clearTimeout(hiddenOutputRestoreFloodRepaintTimer) + hiddenOutputRestoreFloodRepaintTimer = null } + cleanupHiddenOutputRestoreFloodRepaint = clearHiddenOutputRestoreFloodRepaintTimer + + function resetHiddenOutputRestoreFloodSuppression(): void { + hiddenOutputRestoreFloodSuppressedUntil = 0 + clearHiddenOutputRestoreFloodRepaintTimer() + } + + // Extends the suppression window and (re)schedules the single deferred + // repaint for when the flood goes quiet. Every backpressure signal resets + // the timer, so it fires exactly once, SUPPRESS_MS after the last signal. + function noteHiddenOutputRestoreFloodBackpressure(): void { + hiddenOutputRestoreFloodSuppressedUntil = Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + const ptyId = transport.getPtyId() + if (ptyId === null) { + return + } + clearHiddenOutputRestoreFloodRepaintTimer() + hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { + hiddenOutputRestoreFloodRepaintTimer = null + if (disposed || transport.getPtyId() !== ptyId) { + return + } + // Why one repaint: bytes were dropped during the flood, so the screen + // has a gap the live stream cannot heal. Now that the flood is quiet, + // a single snapshot restore repaints from main's authoritative buffer. + markHiddenOutputRestoreNeeded() + }, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS) + } + + // Why: main reports dropped renderer-bound bytes (hidden gate / pending + // cap) out-of-band — routed per PTY by pty-model-restore-channel.ts. + function handleModelRestoreNeededMarker(): void { + if (disposed) { + return + } + recordTerminalFreezeBreadcrumb('restore-marker', { + id: redactPtyIdForDiagnostics(transport.getPtyId() ?? '') + }) + // Why: dropped bytes invalidate every cross-chunk carry — a partial + // OSC-9999 prefix spanning the gap would corrupt the next live chunk. + transport.resetCrossChunkParserState?.() + // Why gated (rc.7.perf loop): on a visible pane these markers are the + // product of our own restore starving ACKs. Re-arming per marker kept + // the snapshot-fetch loop alive for the whole flood; defer to one + // post-flood repaint instead and let live bytes flow. + if (isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + return + } + // Why: parity with the hidden skip path — a marker landing while a + // restore is in flight means the in-flight snapshot may predate the + // drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a + // visible pane the mark starts a restore synchronously, which must not + // count as "already in flight". + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + } + + function syncModelRestoreNeededSubscription(ptyId: string | null): void { + if (modelRestoreSubscribedPtyId === ptyId) { + return + } + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = ptyId + // Why: markers exist only for PTYs whose bytes transit local main; + // remote-runtime transports are structurally unaffected. + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + return + } + unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( + ptyId, + handleModelRestoreNeededMarker + ) + } + + syncHiddenRendererPtyDelivery = (): void => { + const ptyId = transport.getPtyId() + syncModelRestoreNeededSubscription(ptyId) + if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + } + if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { + return + } + const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId + hiddenDeliverySyncedPtyId = ptyId + if (shouldHide) { + if (!releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) + } + } else if (releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim() + releaseHiddenDeliveryClaim = null + } else if (isFirstSyncForPty) { + // Why: clear unconditionally on the first sync for a PTY — a stale + // main-side hidden bit can survive a renderer reload for + // daemon-backed PTYs that keep their session id. + declareRendererPtyDeliveryVisible(ptyId) + } + } + releaseHiddenRendererPtyDelivery = (): void => { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = null + } + function beforeTerminalOutputWrite(): void { recordTerminalOutput(pane.terminal) } @@ -4488,6 +4691,27 @@ export function connectPanePty( } } + function respondToSkippedMode2031Subscribe(data: string): void { + const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) + hiddenMode2031ScanTail = scan.tail + if (scan.finalState === 'unsubscribed') { + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } + if (scan.finalState !== 'subscribed') { + return + } + const settings = useAppStore.getState().settings + const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) + // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer + // mode 2031 out-of-band so TUIs still render the snapshot with the same + // theme-dependent styling they would have used in a visible pane. + deps.paneMode2031Ref.current.set(pane.id, true) + transport.sendInput(mode2031SequenceFor(mode)) + deps.paneLastThemeModeRef.current.set(pane.id, mode) + recordHiddenMode2031Reply() + } + function writePtyOutputToXterm( data: string, foreground: boolean, @@ -4578,6 +4802,11 @@ export function connectPanePty( writeTerminalOutput(pane.terminal, data, { foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, + // Why: claims the in-progress pty:data delivery's parse-deferred ACK + // (null outside a delivery, e.g. snapshot replays / synthetic writes). + // The FIRST scheduler write of a delivery carries the whole credit; + // the scheduler fires it when the bytes are consumed. + ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput @@ -4751,6 +4980,64 @@ export function connectPanePty( recordHiddenRendererSkip(data.length) } + // Why: discarding queued flood bytes must never swallow terminal queries — + // a lost DSR/CPR (or color/DA) reply hangs the querying program (the bench + // DSR timeout). The discarded CONTENT is owned by the snapshot repaint. + // Replies are SYNTHESIZED directly (transport.sendInput) instead of + // replaying the queries into xterm: a drop always triggers a snapshot + // restore, whose replay guard swallows xterm auto-replies and whose + // discardTerminalOutput races away queued query writes — both killed the + // salvaged reply in practice. Only called for bytes being thrown away, so + // replies cannot double-fire against a later queue drain. + function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { + if (!data || !data.includes('\x1b')) { + return + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + if (extracted.oscColorQueryData) { + sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => + transport.sendInput(reply) + ) + } + let unansweredQueryData = '' + for (const sequence of splitCsiSequences( + extracted.statefulQueryData + extracted.statelessQueryData + )) { + if (sequence === '\x1b[6n') { + // CPR from the live buffer. Position may be mid-repaint stale — in a + // drop scenario positional accuracy is already forfeit; liveness is + // the contract (a blocked reader must unblock). + const buffer = pane.terminal.buffer.active + const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) + const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) + transport.sendInput(`\x1b[${row};${col}R`) + } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { + transport.sendInput(DEFAULT_DA1_RESPONSE) + } else { + unansweredQueryData += sequence + } + } + if (unansweredQueryData) { + // Best-effort for the rarer queries (DECRQM, DA2, XTVERSION): replay + // into xterm and let its handlers answer when no replay is active. + writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) + } + } + + function splitCsiSequences(queryData: string): string[] { + const sequences: string[] = [] + let offset = queryData.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) + if (finalByteIndex === -1) { + break + } + sequences.push(queryData.slice(offset, finalByteIndex + 1)) + offset = queryData.indexOf('\x1b[', finalByteIndex + 1) + } + return sequences + } + function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { if (!data) { return @@ -4764,10 +5051,23 @@ export function connectPanePty( } hiddenOutputRestorePtyId = ptyId hiddenOutputRestoreNeeded = true + if (hiddenOutputRestorePendingOverflow) { + // Why: the overflow latch means everything queued gets discarded at the + // next drain — queueing more only grows the discard. Salvage queries, + // drop the content. + salvageRendererQueriesFromDiscardedRestoreData(data) + armHiddenOutputRestoreForegroundDeadline() + return + } if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { + const discardedChunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 hiddenOutputRestorePendingOverflow = true + for (const chunk of discardedChunks) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } + salvageRendererQueriesFromDiscardedRestoreData(data) armHiddenOutputRestoreForegroundDeadline() return } @@ -4805,6 +5105,75 @@ export function connectPanePty( return chunk.data.slice(offset) } + type RestoredSnapshotReconciliation = + | { action: 'write'; data: string; meta: PtyDataMeta | undefined } + | { action: 'drop-duplicate' } + | { action: 'force-fresh-restore' } + + // Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE + // chunks after a restore completed — main's ACK backlog keeps draining + // chunks at or before the snapshot seq, and pending-cap trims can drop + // seq ranges silently once the one-shot overflow marker was consumed. + function reconcileChunkAgainstRestoredSnapshot( + data: string, + meta: PtyDataMeta | undefined + ): RestoredSnapshotReconciliation { + if (restoredSnapshotBaselineSeq === null) { + return { action: 'write', data, meta } + } + if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + if (typeof meta?.seq !== 'number') { + // Why: seq-less chunks (no runtime metering) cannot be reconciled; + // mirror getChunkDataAfterSnapshot and pass them through. + return { action: 'write', data, meta } + } + if ( + restoredSnapshotDeliveryWindowStartSeq !== null && + meta.seq <= restoredSnapshotDeliveryWindowStartSeq + ) { + // Why: every byte main could still deliver at snapshot time started + // AFTER this seq, and delivery is once-and-in-order — so this chunk + // cannot be a backlog duplicate. It is a new seq domain (restarted + // counter / synthetic source); retire the stale baseline and write + // instead of silently dropping genuinely-new live output. + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + const rawLength = meta.rawLength ?? data.length + const startSeq = meta.seq - rawLength + const expectedStartSeq = restoredSnapshotExpectedStartSeq + restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) + if (expectedStartSeq !== null && startSeq > expectedStartSeq) { + // Why: the chunk starts past the continuity point — bytes between + // were dropped (pending-cap trim after the marker fired). Only the + // model snapshot can heal the gap. + return { action: 'force-fresh-restore' } + } + if (meta.seq <= restoredSnapshotBaselineSeq) { + return { action: 'drop-duplicate' } + } + if (startSeq >= restoredSnapshotBaselineSeq) { + return { action: 'write', data, meta } + } + if (rawLength !== data.length) { + // Why: renderer-only OSC stripping makes raw sequence offsets + // impossible to map onto cleaned text — fetch a fresh snapshot + // instead of risking duplicate visible output. + return { action: 'force-fresh-restore' } + } + const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) + return { + action: 'write', + data: sliced, + // Why: keep seq metadata consistent with the sliced payload so a + // later restore queue drain slices against accurate offsets. + meta: { ...meta, rawLength: sliced.length } + } + } + function recordRendererOrderedSeq(meta?: Pick): void { if (typeof meta?.seq !== 'number') { return @@ -4825,6 +5194,12 @@ export function connectPanePty( // 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. + // The restored-snapshot baseline is a seq high-water mark too and must + // die at the same boundary, or reconcile drops revived chunks as + // duplicates. + if (restoredSnapshotBaselinePtyId === exitedPtyId) { + clearRestoredSnapshotBaseline() + } if (rendererOrderedPtyId === exitedPtyId) { rendererOrderedPtyId = null rendererOrderedSeq = null @@ -4878,26 +5253,39 @@ export function connectPanePty( ) } - function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { + // 'drained' painted every queued live byte; 'overflow' means the queue + // blew its cap during this restore (the stream is outrunning snapshot + // fetch+replay); 'refetch' means offsets were unmappable and only a + // fresher snapshot can realign. + function drainPendingLiveChunksAfterSnapshot( + snapshotSeq: number | undefined + ): 'drained' | 'overflow' | 'refetch' { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } while (hiddenOutputRestorePendingChunks.length > 0) { const chunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 - for (const chunk of chunks) { + for (const [index, chunk] of chunks.entries()) { const data = getChunkDataAfterSnapshot(chunk, snapshotSeq) if (data === null) { // Why: renderer-only OSC stripping makes raw sequence offsets // impossible to map onto cleaned text. Fetch a fresher main // snapshot instead of risking duplicate visible output. - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + for (const discarded of chunks.slice(index)) { + salvageRendererQueriesFromDiscardedRestoreData(discarded.data) + } + discardPendingLiveChunksSalvagingQueries() + return 'refetch' + } + // Why: drained chunks advance the post-restore continuity point so + // the live-chunk reconciliation neither re-drops them as duplicates + // nor misreads the next live chunk as a gap. + if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { + restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) } if (data) { writePtyOutputToXterm(data, true) @@ -4906,12 +5294,20 @@ export function connectPanePty( } if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } } - return true + return 'drained' + } + + function discardPendingLiveChunksSalvagingQueries(): void { + const discarded = hiddenOutputRestorePendingChunks + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + for (const chunk of discarded) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } } function clearPendingLiveChunksDuringRestore(): void { @@ -4975,7 +5371,10 @@ export function connectPanePty( }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) } - function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void { + function abandonHiddenOutputRestoreAndDrainPendingForeground( + expectedPtyId: string, + opts: { quiet?: boolean } = {} + ): void { if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) { resetHiddenOutputRestoreIfPtyChanged() return @@ -5002,7 +5401,12 @@ export function connectPanePty( clearHiddenOutputRestoreForegroundDeadlineTimer() hiddenOutputRestoreDeferredRetryAttempts = 0 - writeRestoreUnavailableWarning() + // Why quiet exists: flood cuts abandon deliberately and schedule a + // post-flood repaint — the "restore unavailable" warning would be + // misleading noise the repaint immediately wipes. + if (!opts.quiet) { + writeRestoreUnavailableWarning() + } if (hadPendingOverflow) { return } @@ -5101,7 +5505,10 @@ export function connectPanePty( // Why: renderer backlog is tied to the old PTY stream; after reattach, // queued hidden bytes must not delay or replay before the new PTY. clearHiddenOutputRestoreState() + clearRestoredSnapshotBaseline() clearPaneMode2031State() + // Why: flood-backpressure evidence is per PTY stream too. + resetHiddenOutputRestoreFloodSuppression() discardTerminalOutput(pane.terminal) } } @@ -5122,6 +5529,7 @@ export function connectPanePty( rows: number seq?: number alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string }): void { const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) @@ -5153,13 +5561,19 @@ 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 if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\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('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) // Why: status/title-corroborated live agents own ?25l/?1004h (a forced @@ -5171,10 +5585,11 @@ export function connectPanePty( : POST_REPLAY_LIVE_SNAPSHOT_RESET ) if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was serialized while the emulator sat mid-escape; - // re-arm the dangling sequence as the FINAL replay write (any later ESC — - // including the reset above — would abort it) so the racing live tail's - // continuation completes it instead of rendering literally (#7329). + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). writeReplayData(snapshot.pendingEscapeTailAnsi) } hiddenRendererStateDirty = false @@ -5255,6 +5670,10 @@ export function connectPanePty( hiddenOutputRestoreRetryDeferred = false hiddenOutputRestoreInFlight = (async () => { + // Backstop for the rc.7.perf feedback loop: bound how many snapshot + // fetch+replay rounds one task may burn before it must yield to the + // live stream. + let restoreIterations = 0 while (!disposed) { const currentPtyId = hiddenOutputRestorePtyId if (currentPtyId === null) { @@ -5279,7 +5698,7 @@ export function connectPanePty( let snapshot: PtyBufferSnapshot | null = null try { snapshot = await serializeHiddenOutputSnapshot(currentPtyId, { - scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) }) } catch { snapshot = null @@ -5308,10 +5727,16 @@ export function connectPanePty( return } hiddenOutputRestoreDeferredRetryAttempts = 0 + restoreIterations += 1 applyMainBufferSnapshot(snapshot) + // Why: everything at or before snapshot.seq is now painted; chunks + // still draining from main's ACK backlog below that point are + // duplicates the dataCallback reconciliation must suppress. + setRestoredSnapshotBaseline(currentPtyId, snapshot) const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false - if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) { + const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) + if (drainOutcome === 'drained' && !needsFreshSnapshot) { hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -5324,6 +5749,31 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true return } + if (drainOutcome === 'overflow') { + // Cut 1 of the rc.7.perf feedback loop: a FOREGROUND queue + // overflow means the live stream outruns snapshot fetch+replay. + // Re-fetching would starve ACK processing again and feed the + // drop/re-arm cycle — abandon now, let bytes write through, and + // heal with one repaint after the flood. + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) { + // Backstop: fresh-snapshot marks / unmappable slices re-looping + // this many times means the stream is winning the race. + warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', { + tabId: deps.tabId, + worktreeId: deps.worktreeId, + leafId: pane.leafId, + paneId: pane.id, + ptyId: currentPtyId, + reason: drainOutcome + }) + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } hiddenOutputRestoreNeeded = true } })() @@ -5349,23 +5799,38 @@ export function connectPanePty( return true } - unregisterBacklogRecovery = registerTerminalBacklogRecovery( - pane.terminal, - requestHiddenOutputRestoreIfNeeded - ) + unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { + // Why: clear the hidden-delivery bit BEFORE the restore snapshot + // request — bytes arriving between the unhide IPC and the snapshot + // are reconciled by the existing seq guard. + syncHiddenRendererPtyDelivery() + return requestHiddenOutputRestoreIfNeeded() + }) if ( typeof document !== 'undefined' && typeof document.addEventListener === 'function' && typeof document.removeEventListener === 'function' ) { const onDocumentVisibilityChange = (): void => { + // Why: document hide/show flips the foreground predicate without any + // pane lifecycle event — re-sync the hidden-delivery gate both ways. + syncHiddenRendererPtyDelivery() if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { requestHiddenOutputRestoreIfNeeded() } } document.addEventListener('visibilitychange', onDocumentVisibilityChange) - unregisterDocumentVisibilityRecovery = () => + // Why: when user input proves visibilityState is wedged at 'hidden' + // (stale macOS occlusion), run the same resync — no visibilitychange + // will ever fire in that state, and the gate would drop watched bytes + // forever. + const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery( + onDocumentVisibilityChange + ) + unregisterDocumentVisibilityRecovery = () => { document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + unregisterStaleVisibilityRecovery() + } } const dataCallback = (data: string, meta?: PtyDataMeta): void => { @@ -5385,28 +5850,86 @@ export function connectPanePty( } observeStartupDraftPasteReadiness(data) resetHiddenOutputRestoreIfPtyChanged() - if (meta?.droppedBacklog === true) { - // Why: main trimmed this pty's unsent backlog (the renderer was - // background-throttled/frozen and stopped ACKing). Rebuild the dropped - // span from the main headless snapshot — same recovery the renderer's - // own 2 MB scheduler overflow uses. No-op cost when already visible+synced. - markHiddenOutputRestoreNeeded() + if (meta?.droppedOutput === true) { + // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own + // restore is backpressure the restore itself caused — re-arming per + // sentinel kept the snapshot-fetch loop alive for the whole flood. + // Defer to one post-flood repaint; any carved-out query bytes riding + // the sentinel still flow through the normal write path below. + if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + } else { + // Why: main dropped this PTY's buffered output at the pending cap + // (renderer was not receiving). The stream has a gap, so repaint the + // pane from the main-owned buffer snapshot instead of writing on. + markHiddenOutputRestoreNeeded() + if (data) { + // The sentinel can carry query bytes carved out of the bulk drop + // (extractDroppedPtyQueryBytes in main) — replies must still flow. + salvageRendererQueriesFromDiscardedRestoreData(data) + } + return + } } respondToTerminalPixelSizeQueries(data) observeTerminalBracketedPasteModeOutput(pane.terminal, data) - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + // Why: with main side-effect authority, command-finished, pr-link, and + // the Command Code scrape arrive as pty:sideEffect facts — + // byte-scanning here too would double-fire the same policy. + // Remote-runtime PTYs (and the kill switch off) keep this byte path as + // their only parser. + if (!mainSideEffectAuthority) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + } + commandLifecycle.handlePtyData(data) } - commandCodeOutputStatusDetector.observe(data) - commandLifecycle.handlePtyData(data) + commandCodeOutputStatusDetector?.observe(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true + // Why: latch the hidden-delivery gate from the byte path too — covers a + // PTY id arriving after the initial sync. No-op when state is current. + if (!foreground) { + syncHiddenRendererPtyDelivery() + } if (foreground && hiddenMode2031ScanTail) { respondToSkippedMode2031Subscribe(data) } + // Why: post-restore reconciliation — drop/slice backlog chunks the + // restored snapshot already covers, and force a fresh restore for seq + // gaps or overlaps whose offsets cannot be mapped. Runs after the byte + // observers above (those bytes were never delivered before; their side + // effects are still real) but before any xterm write decision. + const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) + if (reconciliation.action === 'drop-duplicate') { + return + } + if (reconciliation.action === 'force-fresh-restore') { + // Why gated (rc.7.perf loop): during a foreground flood the seq gaps + // come from our own backpressure drops — fetching a snapshot per gap + // IS the feedback loop. Retire the stale baseline, write the post-gap + // bytes through, and heal with one repaint after the flood. + if (foreground && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + clearRestoredSnapshotBaseline() + // fall through with the ORIGINAL data/meta — post-gap bytes are new + } else { + // Why: in-flight captured BEFORE the mark — on a visible pane the + // mark starts the restore synchronously and must not flag itself. + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + return + } + } else { + data = reconciliation.data + meta = reconciliation.meta + } // Why: a hidden Codex query can be split just before visibility changes; // xterm needs the completed query, while other bytes still follow restore. const pendingForegroundQuery = foreground @@ -5472,7 +5995,15 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } + // Why: hidden chunks with a restore already latched are dropped here — + // the model snapshot fetched on reveal covers their bytes. } else { + // Why: gate-managed hidden panes normally receive no bytes (main + // drops after model ingestion). Any hidden chunk that still arrives + // (kill switch off, interest-held delivery) rides the bounded + // background scheduler queue; on overflow the scheduler latches the + // model restore. The kill-switch-off startup-query grammar above is + // the byte-identical fallback. if (pendingForegroundQuery?.statefulQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { hiddenStartupRendererQuery: true @@ -5551,6 +6082,8 @@ export function connectPanePty( } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() @@ -5905,6 +6438,7 @@ export function connectPanePty( ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6088,6 +6622,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6348,6 +6883,12 @@ export function connectPanePty( return { syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() + // Why: the lifecycle hook calls this on every pane visibility flip — + // the hidden-delivery gate must follow the same transitions. + syncHiddenRendererPtyDelivery() + }, + isHiddenDeliveryGateManagedPty() { + return isHiddenDeliveryGateManagedPty(transport.getPtyId()) }, // Why: called from the lifecycle visibility effect so the visible-resume // size readback can repair dropped hidden resizes without refitting against @@ -6416,6 +6957,9 @@ export function connectPanePty( cancelAnimationFrame(pendingForegroundGridDriftCheckRaf) pendingForegroundGridDriftCheckRaf = null } + // Why: a pane unmount (tab move, parking teardown) must never leave its + // PTY gated — the parked watcher or the remounted pane re-decides. + releaseHiddenRendererPtyDelivery() if (terminalKeyTargetSupportsEvents) { terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) } @@ -6451,11 +6995,15 @@ export function connectPanePty( } cleanupHiddenOutputRestoreDeferredRetry() cleanupHiddenOutputRestoreForegroundDeadline() + cleanupHiddenOutputRestoreFloodRepaint() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() unregisterDocumentVisibilityRecovery = null - reportPanePtyVisibility(activePanePtyBinding ?? transport.getPtyId(), false) + releaseRendererPtyVisibilityClaim(transport) + // Why: a parked-tab watcher may take over this PTY's facts in the same + // effect flush; the pane's consumer must be gone before that handoff. + dropSideEffectFactConsumer() clearPanePtyFitBinding() discardTerminalOutput(pane.terminal) unregisterE2ePtyDataInjection() diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index af06e1b4a26..3625a8e6007 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -1,9 +1,14 @@ +import { acquirePtyDeliveryInterest } from './pty-delivery-interest' import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' /** Register a side-channel data watcher for a PTY without taking ownership * of the primary handler. Returns an unsubscribe fn. */ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { ensurePtyDispatcher() + // Why: a sidecar is, by definition, a raw-byte consumer — its registration + // doubles as the delivery-interest signal that suppresses main's + // hidden-delivery gate (terminal-side-effect-authority.md, Open Items). + const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) let set = ptyDataSidecars.get(ptyId) if (!set) { set = new Set() @@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi } set.add(watcher) return () => { + releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) if (!current) { return diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts new file mode 100644 index 00000000000..a695b77d82f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -0,0 +1,42 @@ +/** + * Renderer-side delivery-interest registry for the Phase-4 hidden-delivery + * gate (docs/reference/terminal-side-effect-authority.md, Open Items). + * + * Why: main only drops hidden PTY byte delivery while NO renderer party needs + * raw bytes. Dispatcher sidecars and eager pre-mount buffers register + * interest here; ref-counted so main sees only the 0↔1 transitions. + */ +const ptyDeliveryInterestRefCounts = new Map() + +function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested) +} + +/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is + * safe to call more than once (only the first call decrements). */ +export function acquirePtyDeliveryInterest(ptyId: string): () => void { + const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1 + ptyDeliveryInterestRefCounts.set(ptyId, next) + if (next === 1) { + sendPtyDeliveryInterest(ptyId, true) + } + let released = false + return () => { + if (released) { + return + } + released = true + const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0 + if (current <= 1) { + ptyDeliveryInterestRefCounts.delete(ptyId) + sendPtyDeliveryInterest(ptyId, false) + } else { + ptyDeliveryInterestRefCounts.set(ptyId, current - 1) + } + } +} + +/** Test seam: drop ref counts between tests (no IPC is sent). */ +export function _resetPtyDeliveryInterestForTest(): void { + ptyDeliveryInterestRefCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts new file mode 100644 index 00000000000..c8f84865fed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -0,0 +1,107 @@ +// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer +// party needs them. These tests pin the dispatcher-side interest signal: every +// subscribeToPtyData sidecar must surface a ref-counted delivery-interest hold +// to main. Eager buffers are model-recoverable and must not defeat hidden gating. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery interest', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let setPtyDeliveryInterest: ReturnType + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + exitCallback = null + setPtyDeliveryInterest = vi.fn() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + setPtyDeliveryInterest, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('registers interest on the first sidecar and releases on the last unsubscribe', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true) + + // Why: ref-counted — main only sees the 0↔1 transitions. + const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribeFirst() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + unsubscribeSecond() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('releases sidecar interest only once for repeated unsubscribes', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + unsubscribe() + unsubscribe() + + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('does not let an eager pre-mount buffer defeat hidden delivery gating', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + const handle = registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + handle.dispose() + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('keeps eager buffers outside delivery interest when the PTY exits before mount', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + exitCallback?.({ id: 'pty-eager', code: 0 }) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('lets a sidecar exclusively own interest while an eager buffer overlaps', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const handle = registerEagerPtyBuffer('pty-1', vi.fn()) + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + handle.dispose() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribe() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts new file mode 100644 index 00000000000..7febefe5126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts @@ -0,0 +1,92 @@ +// Why: pins the renderer half of the delivery-resync protocol — the singleton +// dispatcher must answer main's probe with the cumulative processed totals +// that back its ACKs, and must drop a PTY's total once it exits. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery resync', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallback: + | ((payload: { id: string; data: string; rawLength?: number; background?: boolean }) => void) + | null = null + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + let resyncRequestCallback: ((payload: { requestId: number }) => void) | null = null + const ackDataMock = vi.fn() + const respondDeliveryResyncMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + dataCallback = null + exitCallback = null + resyncRequestCallback = null + ackDataMock.mockClear() + respondDeliveryResyncMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + onData: vi.fn( + ( + cb: (payload: { + id: string + data: string + rawLength?: number + background?: boolean + }) => void + ) => { + dataCallback ??= cb + return () => {} + } + ), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + onDeliveryResyncRequest: vi.fn((cb: (payload: { requestId: number }) => void) => { + resyncRequestCallback ??= cb + return () => {} + }), + respondDeliveryResync: respondDeliveryResyncMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('answers delivery resync probes with cumulative totals and drops exited PTYs', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + ensurePtyDispatcher() + + dataCallback?.({ id: 'pty-1', data: 'hello' }) + dataCallback?.({ id: 'pty-1', data: 'world!!', rawLength: 7 }) + dataCallback?.({ id: 'pty-2', data: 'abc' }) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-1', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-1', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-2', 3, 3) + + resyncRequestCallback?.({ requestId: 7 }) + expect(respondDeliveryResyncMock).toHaveBeenCalledWith({ + requestId: 7, + processedCharsByPty: { 'pty-1': 12, 'pty-2': 3 } + }) + + exitCallback?.({ id: 'pty-1', code: 0 }) + resyncRequestCallback?.({ requestId: 8 }) + expect(respondDeliveryResyncMock).toHaveBeenLastCalledWith({ + requestId: 8, + processedCharsByPty: { 'pty-2': 3 } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index bce9767bd85..2df88ac2973 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -92,7 +92,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: 'chunk', rawLength: 10, background: true } as never) expect(handler).toHaveBeenCalledWith('chunk', { rawLength: 10, background: true }) - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10, 10) ptyDataHandlers.delete('pty-pi') }) @@ -299,7 +299,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 }) expect(onData).not.toHaveBeenCalled() - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19, 19) resolveSpawn({ id: 'pty-early' }) await connectPromise diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts new file mode 100644 index 00000000000..7430231bf04 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts @@ -0,0 +1,111 @@ +// Why: unit-level repro of the field wedge (v1.4.121-rc.0) — push events +// vanish before the dispatcher sees them, so no handler runs and no ACK is +// ever produced — plus the recovery seam: reattachPtyDispatcherPushListeners +// must drop the stale subscriptions and bind fresh ones. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) + +type DataCallback = (payload: { id: string; data: string; rawLength?: number }) => void + +describe('pty dispatcher push-listener reattach and delivery blackhole', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallbacks: DataCallback[] = [] + let dataUnsubscribes: ReturnType[] = [] + const ackDataMock = vi.fn() + const reportMock = vi.fn(() => + Promise.resolve({ + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: null + }) + ) + + beforeEach(() => { + vi.resetModules() + dataCallbacks = [] + dataUnsubscribes = [] + ackDataMock.mockClear() + reportMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: () => dataCallbacks.length, + onData: vi.fn((cb: DataCallback) => { + dataCallbacks.push(cb) + const unsubscribe = vi.fn() + dataUnsubscribes.push(unsubscribe) + return unsubscribe + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + onDeliveryResyncRequest: vi.fn(() => () => {}), + respondDeliveryResync: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(async () => { + // Why: ensurePtyDispatcher started the watchdog's real-timer interval; + // stop it so no tick outlives this file's mocked window. + const { stopTerminalDeliveryWatchdog } = await import('./terminal-delivery-watchdog') + stopTerminalDeliveryWatchdog() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('blackholed delivery reproduces the wedge: no handler dispatch, no ACK ever', async () => { + const { ensurePtyDispatcher, ptyDataHandlers } = await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + + dataCallbacks[0]?.({ id: 'pty-1', data: 'before-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + const blackhole = ( + window as Window & { + __terminalDeliveryWatchdog?: { blackhole: (on: boolean) => void } + } + ).__terminalDeliveryWatchdog + expect(blackhole).toBeDefined() + blackhole!.blackhole(true) + + // The field failure in miniature: the chunk vanishes with no receive + // count and no ACK — main's in-flight debt for it can never be repaid. + dataCallbacks[0]?.({ id: 'pty-1', data: 'lost-in-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + blackhole!.blackhole(false) + }) + + it('reattach drops stale push subscriptions and binds fresh ones', async () => { + const { ensurePtyDispatcher, ptyDataHandlers, reattachPtyDispatcherPushListeners } = + await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + expect(dataCallbacks).toHaveLength(1) + + reattachPtyDispatcherPushListeners() + + expect(dataUnsubscribes[0]).toHaveBeenCalledTimes(1) + expect(dataCallbacks).toHaveLength(2) + + dataCallbacks[1]?.({ id: 'pty-1', data: 'after-reattach' }) + expect(received).toEqual(['after-reattach']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 4c3857d9a09..b563443d1ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -6,7 +6,12 @@ * and the eager-buffer reconnection logic share. */ import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' +import { + clearProcessedPtyCharTotal, + deliverPtyDataWithDeferredAck, + exposeE2eTerminalPtyAckGate, + getProcessedPtyCharTotals +} from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { bufferPreHandlerPtyData, @@ -15,6 +20,14 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { + clearReceivedPtyCharTotal, + isPtyPushDeliveryBlackholed, + recordPtyDataReceived, + startTerminalDeliveryWatchdog +} from './terminal-delivery-watchdog' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { installTerminalFreezeReport } from './terminal-freeze-report' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -25,9 +38,9 @@ export type PtyDataMeta = { seq?: number rawLength?: number background?: boolean - /** Main trimmed this pty's unsent backlog past its cap; the handler should - * rebuild the dropped span from the main headless snapshot. */ - droppedBacklog?: boolean + /** Main dropped this PTY's buffered output at the pending cap; the pane + * must repaint from the main-owned snapshot instead of the live stream. */ + droppedOutput?: boolean } export const ptyDataHandlers = new Map void>() @@ -102,78 +115,151 @@ export function restorePtyDataHandlersAfterFailedShutdown( } } +let pushListenerUnsubscribes: (() => void)[] = [] + +/** Detach and freshly re-subscribe every push-channel listener. Called by the + * delivery watchdog on a confirmed wedge: if the listener was somehow + * detached this restores delivery outright; if the channel itself is dead + * it is a safe no-op (removeListener on a gone listener does nothing). */ +export function reattachPtyDispatcherPushListeners(): void { + recordTerminalFreezeBreadcrumb('push-listeners-reattach', { + staleListenerCount: pushListenerUnsubscribes.length + }) + const stale = pushListenerUnsubscribes + pushListenerUnsubscribes = [] + for (const unsubscribe of stale) { + unsubscribe() + } + attachPtyPushListeners() +} + export function ensurePtyDispatcher(): void { if (ptyDispatcherAttached) { return } ptyDispatcherAttached = true exposeE2eTerminalPtyAckGate() - window.api.pty.onData((payload) => { - try { - let meta: PtyDataMeta | undefined - if (typeof payload.seq === 'number') { - meta ??= {} - meta.seq = payload.seq + installTerminalFreezeReport() + attachPtyPushListeners() + startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachPtyDispatcherPushListeners, + hasAttachedPtys: () => ptyDataHandlers.size > 0 || eagerPtyHandles.size > 0 + }) +} + +function attachPtyPushListeners(): void { + const unsubscribes = pushListenerUnsubscribes + unsubscribes.push( + window.api.pty.onData((payload) => { + // Why: e2e-only wedge simulation — the chunk vanishes exactly as in the + // field failure: no receive count, no ACK credit, no handler dispatch. + if (isPtyPushDeliveryBlackholed()) { + return } - if (typeof payload.rawLength === 'number') { - meta ??= {} - meta.rawLength = payload.rawLength + handleDispatchedPtyData(payload) + }) + ) + attachPtySecondaryPushListeners(unsubscribes) +} + +function handleDispatchedPtyData(payload: { + id: string + data: string + seq?: number + rawLength?: number + background?: boolean + droppedOutput?: boolean +}): void { + let meta: PtyDataMeta | undefined + if (typeof payload.seq === 'number') { + meta ??= {} + meta.seq = payload.seq + } + if (typeof payload.rawLength === 'number') { + meta ??= {} + meta.rawLength = payload.rawLength + } + if (payload.background === true) { + meta ??= {} + meta.background = true + } + if (payload.droppedOutput === true) { + meta ??= {} + meta.droppedOutput = true + } + const chars = payload.rawLength ?? payload.data.length + const dispatch = (): void => { + const handler = ptyDataHandlers.get(payload.id) + if (handler) { + handler(payload.data, meta) + } else { + bufferPreHandlerPtyData(payload.id, payload.data, meta) + } + const sidecars = ptyDataSidecars.get(payload.id) + if (sidecars && sidecars.size > 0) { + // Why: snapshot the Set before iterating because watchers commonly + // unsubscribe themselves on the very chunk that satisfies them + // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately + // tears down). Iterating the live Set in that case can skip a + // watcher or — if a watcher synchronously subscribes a sibling — + // double-fire. The Set is never large (one watcher per active + // ready-wait), so the array allocation is cheap. + const snapshot = Array.from(sidecars) + for (const watcher of snapshot) { + watcher(payload.data) } - if (payload.background === true) { - meta ??= {} - meta.background = true - } - if (payload.droppedBacklog === true) { - meta ??= {} - meta.droppedBacklog = true - } - const handler = ptyDataHandlers.get(payload.id) + } + } + recordPtyDataReceived(payload.id, chars) + // Why deferred: main budgets renderer-bound output by bytes PARSED, not + // bytes received. The handler's scheduler write claims this delivery's + // credit and fires it when xterm consumes the bytes; deliveries that never + // reach the scheduler (dropped, pre-mount eager buffer) settle at return — + // a bad sidecar still cannot leave a PTY permanently backpressured. + deliverPtyDataWithDeferredAck(payload.id, chars, dispatch) +} + +function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { + unsubscribes.push( + window.api.pty.onReplay((payload) => { + ptyReplayHandlers.get(payload.id)?.(payload.data) + }) + ) + unsubscribes.push( + window.api.pty.onExit((payload) => { + // Why: main drops its delivery accounting for this pty on exit; drop the + // cumulative totals too so a reused id restarts at zero on both sides. + clearProcessedPtyCharTotal(payload.id) + clearReceivedPtyCharTotal(payload.id) + const handler = ptyExitHandlers.get(payload.id) if (handler) { - handler(payload.data, meta) + clearPreHandlerPtyState(payload.id) + handler(payload.code) } else { - bufferPreHandlerPtyData(payload.id, payload.data, meta) + bufferPreHandlerPtyExit(payload.id, payload.code) } - const sidecars = ptyDataSidecars.get(payload.id) + const sidecars = ptyExitSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { - // Why: snapshot the Set before iterating because watchers commonly - // unsubscribe themselves on the very chunk that satisfies them - // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately - // tears down). Iterating the live Set in that case can skip a - // watcher or — if a watcher synchronously subscribes a sibling — - // double-fire. The Set is never large (one watcher per active - // ready-wait), so the array allocation is cheap. const snapshot = Array.from(sidecars) - for (const watcher of snapshot) { - watcher(payload.data) + ptyExitSidecars.delete(payload.id) + for (const sidecar of snapshot) { + sidecar(payload.code) } } - } finally { - // Why: main budgets renderer-bound terminal output by bytes accepted - // into this dispatcher. ACK in finally so a bad sidecar cannot leave - // a PTY permanently backpressured. - ackPtyData(payload.id, payload.rawLength ?? payload.data.length) - } - }) - window.api.pty.onReplay((payload) => { - ptyReplayHandlers.get(payload.id)?.(payload.data) - }) - window.api.pty.onExit((payload) => { - const handler = ptyExitHandlers.get(payload.id) - if (handler) { - clearPreHandlerPtyState(payload.id) - handler(payload.code) - } else { - bufferPreHandlerPtyExit(payload.id, payload.code) - } - const sidecars = ptyExitSidecars.get(payload.id) - if (sidecars && sidecars.size > 0) { - const snapshot = Array.from(sidecars) - ptyExitSidecars.delete(payload.id) - for (const sidecar of snapshot) { - sidecar(payload.code) - } - } + }) + ) + // Why: main probes when delivery looks stuck on lost ACKs (data arriving + // for a fully gated PTY). Replying with the cumulative processed totals + // lets main reconcile verified state instead of resetting blindly. + const unsubscribeResync = window.api.pty.onDeliveryResyncRequest?.((payload) => { + window.api.pty.respondDeliveryResync?.({ + requestId: payload.requestId, + processedCharsByPty: getProcessedPtyCharTotals() + }) }) + if (unsubscribeResync) { + unsubscribes.push(unsubscribeResync) + } // Why: tell main the pty:data listener is live now. Before this fires (fresh // load or post-reload boot window) main holds all sends — bytes sent into a // listener-less page are silently dropped yet counted in-flight, which @@ -223,7 +309,6 @@ export function registerEagerPtyBuffer( onExit: (ptyId: string, code: number) => void ): EagerPtyHandle { ensurePtyDispatcher() - // Why: a head index instead of Array.shift() — shift() is O(n), making // pre-attach buffering quadratic under many small chunks. Compaction is deferred. const chunks: EagerBufferChunk[] = [] @@ -252,7 +337,7 @@ export function registerEagerPtyBuffer( // Shell died before TerminalPane attached — clean up and notify the store // so the tab's ptyId is cleared and connectPanePty falls through to connect(). // Identity-guarded like dispose(): never delete a handler a transport has - // since registered for this id. + // since registered for this id (#7894 detach/attach remount race). if (ptyDataHandlers.get(ptyId) === dataHandler) { ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) @@ -277,6 +362,8 @@ export function registerEagerPtyBuffer( return data }, dispose() { + // Why: dispose runs at pane attach (mount completed) — the pane's own + // visibility sync now owns the hidden-delivery decision for this PTY. // Only remove if the current handler is still the temp one (compare by // reference). After attach() replaces the handler this becomes a no-op. if (ptyDataHandlers.get(ptyId) === dataHandler) { diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts new file mode 100644 index 00000000000..289192d13d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts @@ -0,0 +1,86 @@ +// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band +// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999 +// stripping). These tests pin the channel routing: one channel subscription, +// handlers keyed by PTY id, replace-on-reregister semantics. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty model-restore channel routing', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onModelRestoreNeeded: ReturnType + let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null + + beforeEach(() => { + vi.resetModules() + channelCallback = null + onModelRestoreNeeded = vi.fn( + (callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => { + channelCallback ??= callback + return () => {} + } + ) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + onModelRestoreNeeded + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('attaches the channel once and routes markers to the registered PTY handler', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handlerA = vi.fn() + const handlerB = vi.fn() + + registerPtyModelRestoreNeededHandler('pty-a', handlerA) + registerPtyModelRestoreNeededHandler('pty-b', handlerB) + expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1) + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerB).not.toHaveBeenCalled() + + // Markers for PTYs without a registered handler are dropped silently. + channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).not.toHaveBeenCalled() + }) + + it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const staleHandler = vi.fn() + const liveHandler = vi.fn() + + const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler) + registerPtyModelRestoreNeededHandler('pty-a', liveHandler) + // Why: a reattaching pane can re-register before the old connection's + // teardown runs — the stale unregister must not remove the live handler. + unregisterStale() + + channelCallback?.({ id: 'pty-a', reason: 'unhide' }) + expect(staleHandler).not.toHaveBeenCalled() + expect(liveHandler).toHaveBeenCalledTimes(1) + }) + + it('stops routing after the live handler unregisters', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handler = vi.fn() + + const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler) + unregister() + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' }) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts new file mode 100644 index 00000000000..350deb25422 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts @@ -0,0 +1,66 @@ +/** + * Singleton router for the out-of-band `pty:modelRestoreNeeded` channel + * (sibling of the pty-dispatcher's data/exit routing — split out to keep the + * dispatcher under the line limit). + * + * Why a dedicated channel + registry: the marker means "main dropped + * renderer-bound bytes (hidden gate / pending cap); restore from the model + * snapshot". It must NOT ride the transport data path — an in-band empty + * chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and + * remote-runtime transports (which never see main's gate) must stay + * structurally unaffected. + */ +import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker' + +const ptyModelRestoreNeededHandlers = new Map void>() +let modelRestoreNeededChannelAttached = false + +function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void { + ptyModelRestoreNeededHandlers.get(event.id)?.(event) +} + +function ensureModelRestoreNeededChannel(): void { + if (modelRestoreNeededChannelAttached) { + return + } + // Why optional-chained: unit tests and the web remote client expose a + // partial pty API; missing channel means "no markers", never a throw. + const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty + ?.onModelRestoreNeeded + if (typeof onModelRestoreNeeded !== 'function') { + return + } + modelRestoreNeededChannelAttached = true + onModelRestoreNeeded(dispatchPtyModelRestoreNeeded) +} + +/** Register the single model-restore-needed handler for a PTY (the pane + * connection that owns its view). A new registration replaces a stale one. */ +export function registerPtyModelRestoreNeededHandler( + ptyId: string, + handler: (event: PtyModelRestoreNeededEvent) => void +): () => void { + ensureModelRestoreNeededChannel() + ptyModelRestoreNeededHandlers.set(ptyId, handler) + return () => { + if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) { + ptyModelRestoreNeededHandlers.delete(ptyId) + } + } +} + +/** Deliver markers fetched over invoke by the delivery watchdog. Same routing + * as the push channel — needed because a delivery-heal fires precisely when + * `pty:modelRestoreNeeded` push events cannot reach this renderer. */ +export function deliverPulledPtyModelRestoreMarkers( + events: readonly PtyModelRestoreNeededEvent[] +): void { + for (const event of events) { + dispatchPtyModelRestoreNeeded(event) + } +} + +/** Test seam: deliver a marker as if it arrived on the channel. */ +export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void { + dispatchPtyModelRestoreNeeded(event) +} diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts new file mode 100644 index 00000000000..5da2177f1b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetPtyRendererDeliveryClaimsForTest, + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' + +const PTY_ID = 'workspace@@pty-1' + +describe('renderer PTY delivery claims', () => { + const setHiddenRendererPty = vi.fn() + const setRendererPtyVisible = vi.fn() + + beforeEach(() => { + _resetPtyRendererDeliveryClaimsForTest() + setHiddenRendererPty.mockReset() + setRendererPtyVisible.mockReset() + ;(globalThis as { window: Window }).window = { + api: { pty: { setHiddenRendererPty, setRendererPtyVisible } } + } as unknown as Window + }) + + it('keeps a PTY hidden across an overlapping pane-to-watcher handoff', () => { + const releasePane = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + const releaseWatcher = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, true) + + releasePane() + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + declareRendererPtyDeliveryVisible(PTY_ID) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + releaseWatcher() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(2) + }) + + it('does not let a retiring visible pane hide its replacement', () => { + const oldPane = {} + const newPane = {} + setRendererPtyVisibilityClaim(oldPane, PTY_ID, true) + setRendererPtyVisibilityClaim(newPane, PTY_ID, true) + + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + releaseRendererPtyVisibilityClaim(oldPane) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + + releaseRendererPtyVisibilityClaim(newPane) + expect(setRendererPtyVisible).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(2) + }) + + it('reports a never-visible mounted pane as known hidden', () => { + setRendererPtyVisibilityClaim({}, PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledWith(PTY_ID, false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts new file mode 100644 index 00000000000..57644da8a83 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts @@ -0,0 +1,122 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const hiddenClaimCounts = new Map() + +type VisibilityClaim = { ptyId: string; visible: boolean } + +const visibilityClaimsByOwner = new Map() +const visibleClaimCounts = new Map() + +function sendHiddenState(ptyId: string, hidden: boolean): void { + recordTerminalFreezeBreadcrumb(hidden ? 'renderer-gate-mark' : 'renderer-gate-unmark', { + id: redactPtyIdForDiagnostics(ptyId) + }) + ;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, hidden) +} + +function sendVisibility(ptyId: string, visible: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setRendererPtyVisible?.(ptyId, visible) +} + +/** + * Holds a hidden-delivery claim until the returned release function runs. + * Main sees only the first-acquire/last-release transitions for each PTY. + */ +export function acquireHiddenRendererPtyDeliveryClaim(ptyId: string): () => void { + const nextCount = (hiddenClaimCounts.get(ptyId) ?? 0) + 1 + hiddenClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendHiddenState(ptyId, true) + } + + let released = false + return () => { + if (released) { + return + } + released = true + const currentCount = hiddenClaimCounts.get(ptyId) ?? 0 + if (currentCount <= 1) { + hiddenClaimCounts.delete(ptyId) + sendHiddenState(ptyId, false) + return + } + hiddenClaimCounts.set(ptyId, currentCount - 1) + } +} + +/** Clears stale main state for a visible PTY without overriding another live + * hidden owner that is still completing a pane-to-watcher handoff. */ +export function declareRendererPtyDeliveryVisible(ptyId: string): void { + if (!hiddenClaimCounts.has(ptyId)) { + sendHiddenState(ptyId, false) + } +} + +function removeVisibleClaim(claim: VisibilityClaim): boolean { + if (!claim.visible) { + return false + } + const currentCount = visibleClaimCounts.get(claim.ptyId) ?? 0 + if (currentCount <= 1) { + visibleClaimCounts.delete(claim.ptyId) + return true + } + visibleClaimCounts.set(claim.ptyId, currentCount - 1) + return false +} + +/** + * Reports one mounted transport's visibility. Ref-counting by owner prevents + * a retiring pane from hiding a PTY after its replacement has already bound. + */ +export function setRendererPtyVisibilityClaim( + owner: object, + ptyId: string, + visible: boolean +): void { + const previous = visibilityClaimsByOwner.get(owner) + if (previous?.ptyId === ptyId && previous.visible === visible) { + return + } + + if (previous) { + const becameHidden = removeVisibleClaim(previous) + if (becameHidden && previous.ptyId !== ptyId) { + sendVisibility(previous.ptyId, false) + } + } + + visibilityClaimsByOwner.set(owner, { ptyId, visible }) + if (visible) { + const nextCount = (visibleClaimCounts.get(ptyId) ?? 0) + 1 + visibleClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendVisibility(ptyId, true) + } + return + } + + if (!visibleClaimCounts.has(ptyId)) { + sendVisibility(ptyId, false) + } +} + +export function releaseRendererPtyVisibilityClaim(owner: object): void { + const previous = visibilityClaimsByOwner.get(owner) + if (!previous) { + return + } + visibilityClaimsByOwner.delete(owner) + if (removeVisibleClaim(previous)) { + sendVisibility(previous.ptyId, false) + } +} + +/** Test seam: renderer reload naturally clears these module-scoped claims. */ +export function _resetPtyRendererDeliveryClaimsForTest(): void { + hiddenClaimCounts.clear() + visibilityClaimsByOwner.clear() + visibleClaimCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 61320e60f56..e5ab8c38cf3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -12,16 +12,23 @@ export type PtyBufferSnapshot = { cols: number rows: number seq?: number + /** Lowest seq main could still deliver when the snapshot was taken (start + * of its pending renderer-delivery queue; equals `seq` when empty). Bytes + * are delivered once and in order, so a post-restore chunk at or below + * this seq can never be a duplicate the snapshot already covers. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' /** True when the snapshot captures an alternate-screen TUI (Claude Code, * vim). Restore must NOT clear xterm's buffer in that case — the TUI's * scrollback lives in xterm and a clear destroys scroll-up after a tab * return. Mirrors the attach-time guard in pty-transport.ts. */ alternateScreen?: boolean - /** Trailing partial escape sequence the source emulator held mid-parse when - * the snapshot was taken. The restorer writes it LAST (after the reset) so a - * racing live continuation completes it instead of rendering literally - * (#7329). */ + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape sequence main's emulator ingested (a PTY read + * ended mid-escape). Must be written LAST — after post-replay resets, right + * before post-snapshot live chunks — so the continuation completes it + * exactly as live instead of rendering literal (Bug E / #7329). */ pendingEscapeTailAnsi?: string } @@ -63,6 +70,11 @@ export type PtyTransport = { cols?: number rows?: number sessionId?: string + /** Hidden-at-spawn declaration (terminal-query-authority.md): no visible + * view will consume this PTY's bytes, so main marks it hidden BEFORE the + * first byte and the gate + model responder own spawn-time queries. + * Ignored by remote-runtime transports (not gate-markable). */ + initiallyHidden?: boolean command?: string env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -97,6 +109,10 @@ export type PtyTransport = { getPtyId: () => string | null getConnectionId?: () => string | null | undefined getLocalSessionMetadata?: () => LocalPtySessionMetadata | null + /** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a + * model-restore marker reports dropped bytes — a carry spanning the gap + * would corrupt the next live chunk. IPC transports only. */ + resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void detach?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index e68a5f9a2db..b676fd2fdfe 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -82,6 +82,37 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const write = window.api.pty.write as unknown as ReturnType + const transport = createIpcPtyTransport({}) + + // Generic spawn failure (e.g. daemon not ready during a startup restore): + // the error IS surfaced via onError, but the transport stays unbound and + // every later keystroke is dropped with no further signal. + spawn.mockRejectedValueOnce(new Error('daemon socket not ready')) + const onError = vi.fn() + await transport.connect({ url: '', callbacks: { onError } }) + expect(onError).toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + + // The tombstoned-session rejection is swallowed with NO callback at all — + // a restored pane that hits it renders persisted content while eating + // keystrokes with zero user-visible signal (Discord #performance / #2836). + spawn.mockRejectedValueOnce(new Error('TerminalKilledError: session xyz was explicitly killed')) + const onErrorKilled = vi.fn() + await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) + expect(onErrorKilled).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + }) + it('ignores a stale exit for a previous PTY after reconnecting the same transport', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -293,6 +324,42 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('runs title side effects even when the data callback does not render the chunk', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport({ onTitleChange }) + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' }) + + expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007') + expect(onTitleChange).not.toHaveBeenCalled() + + await flushPtySideEffects() + + expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title') + transport.disconnect() + }) + + it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => { + // Why: a model-restore marker means bytes were dropped between chunks — + // a partial OSC-9999 prefix carried across that gap would swallow the + // next live chunk's head as bogus status payload. + const { createPtyOutputProcessor } = await import('./pty-transport') + const processor = createPtyOutputProcessor({}) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]9999;', callbacks) + expect(callbacks.onData).toHaveBeenLastCalledWith('') + + processor.resetAgentStatusCarry() + processor.processData('plain output after the gap', callbacks) + + expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap') + }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 7df168d7611..f2c107e441b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -26,7 +26,7 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handle 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' +import { createBellDetector } from '../../../../shared/terminal-bell-detector' import { hasTerminalDisplayContent, trimIncompleteTerminalControlTail @@ -79,7 +79,12 @@ type PtyOutputProcessorOptions = Pick< | 'onAgentBecameWorking' | 'onAgentExited' | 'onAgentStatus' -> +> & { + /** Seed for processors that start mid-session (parked-tab byte watchers): + * the pane's last known title, so a working agent that finishes while the + * processor owns the stream still yields a working→idle transition. */ + initialAgentTitle?: string +} type ProcessPtyOutputOptions = { replayingBufferedData?: boolean @@ -105,7 +110,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle, onAgentBecameWorking, onAgentExited, - onAgentStatus + onAgentStatus, + initialAgentTitle }: PtyOutputProcessorOptions): { processData: ( data: string, @@ -117,10 +123,18 @@ export function createPtyOutputProcessor({ clearStaleTitleTimer: () => void flushPendingSideEffects: () => void resetBellDetector: () => void + resetAgentStatusCarry: () => void } { const bellDetector = createBellDetector() - const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null + // Why `let`: a model-restore marker means bytes were dropped between + // chunks; a partial OSC-9999 prefix carried across that gap would swallow + // the next live chunk's head as bogus payload. Reset recreates the parser. + let processAgentStatusChunk = createAgentStatusOscProcessor() + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session processor behaves as if it had observed the + // pane's last live title — full parity with the live path it replaces. + let lastEmittedTitle: string | null = + initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] @@ -133,7 +147,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle?.(title) }, onAgentBecameWorking, - onAgentExited + onAgentExited, + initialAgentTitle ) : null @@ -444,7 +459,10 @@ export function createPtyOutputProcessor({ clearAccumulatedState, clearStaleTitleTimer, flushPendingSideEffects, - resetBellDetector: () => bellDetector.reset() + resetBellDetector: () => bellDetector.reset(), + resetAgentStatusCarry: () => { + processAgentStatusChunk = createAgentStatusOscProcessor() + } } } @@ -673,6 +691,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : {}), ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), + // Why: hidden-at-spawn mark must land in main before the PTY's + // first byte, so it rides the spawn IPC instead of the pane's + // first visibility sync (terminal-query-authority.md). + ...(options.initiallyHidden ? { initiallyHidden: true } : {}), worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), @@ -959,6 +981,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, + resetCrossChunkParserState() { + // Why: only the OSC-9999 carry spans the dropped-byte gap a + // model-restore marker reports; title/bell trackers re-sync from the + // snapshot's side-effect replay and must not be reset here. + outputProcessor.resetAgentStatusCarry() + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index f870f518c0a..c26acbfca7c 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { isPaneReplaying, @@ -7,6 +7,22 @@ import { type ReplayingPanesRef } from './replay-guard' +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + function makeRef(): ReplayingPanesRef { return { current: new Map() } as ReplayingPanesRef } @@ -142,21 +158,23 @@ describe('replay-guard', () => { }) it('auto-releases the guard when xterm never fires the parse callback', () => { - // Repro of the cold-restore reattach lockout: handleReattachResult replays - // three chunks into a just-mounted / offscreen pane whose terminal never - // flushes, so xterm's parse callback never runs and the counter would stay - // pinned at 3 — isPaneReplaying() stuck true drops EVERY keystroke. The - // fallback must release the guard so input is not swallowed forever. + // Repro of the cold-restore reattach lockout (main #7661): + // handleReattachResult replays three chunks into a just-mounted / + // offscreen pane whose terminal never flushes, so xterm's parse callback + // never runs and the counter would stay pinned at 3 — isPaneReplaying() + // stuck true drops EVERY keystroke. The probe-certified stall path (probe + // never parses either => wedged release) must free the guard. vi.useFakeTimers() try { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H') - replayIntoTerminal(pane, ref, 'scrollback bytes') - replayIntoTerminal(pane, ref, '--- session restored ---') + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) + replayIntoTerminal(pane, ref, 'scrollback bytes', 400) + replayIntoTerminal(pane, ref, '--- session restored ---', 400) expect(isPaneReplaying(ref, 1)).toBe(true) - // Never flush — simulate the missing parse callback for an unflushed pane. + // Never flush — the probe write never parses; the wedged release fires + // one stall window after the probe (400 + 400). vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) @@ -166,18 +184,18 @@ describe('replay-guard', () => { } }) - it('parse completion cancels the fallback without over-releasing', () => { + it('parse completion cancels the stall probe without over-releasing', () => { vi.useFakeTimers() try { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'a') - replayIntoTerminal(pane, ref, 'b') + replayIntoTerminal(pane, ref, 'a', 400) + replayIntoTerminal(pane, ref, 'b', 400) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) - // The already-cancelled fallback must not fire and underflow the counter. + // The already-cancelled stall timer must not fire and underflow the counter. vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) expect(ref.current.has(1)).toBe(false) @@ -192,7 +210,7 @@ describe('replay-guard', () => { const ref = makeRef() const { pane } = makeFakePane(1) let resolved = false - const promise = replayIntoTerminalAsync(pane, ref, 'x').then(() => { + const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { resolved = true }) expect(isPaneReplaying(ref, 1)).toBe(true) @@ -240,3 +258,164 @@ describe('replay-guard', () => { } }) }) + +describe('replay-guard stall handling (probe-certified release)', () => { + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { + // Why this is the load-bearing safety test: a time-based release here + // would leak xterm auto-replies into the shell (and a leaked ESC into an + // agent TUI reads as the user pressing Escape). The guard must only + // release when the pipeline itself proves the replay parsed. + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'slow but alive', 1_000) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Stall check fires: an empty probe write is enqueued behind the replay. + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['slow but alive', '']) + // Probe is pending → replay genuinely still parsing → guard holds. + expect(isPaneReplaying(ref, 1)).toBe(true) + vi.advanceTimersByTime(999) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Parsing finishes: FIFO runs the replay completion first (normal + // release), then the probe completion as a no-op. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + }) + + it('releases when the probe parses but the replay completion was lost, and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.pendingCallbacks.shift() // xterm lost the replay's completion + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + + // The probe's completion firing certifies every earlier replay byte + // parsed — releasing now cannot leak auto-replies. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_lost_completion', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases after the probe itself never parses (wedged pipeline) and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + expect(isPaneReplaying(ref, 1)).toBe(true) + + // A wedged parser will never run the probe callback — and can never + // emit auto-replies either, so this bounded release cannot leak input. + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases immediately when the probe write throws (terminal disposed mid-replay)', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.write = () => { + throw new Error('terminal disposed') + } + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps overlapping engagements independent through a lost completion', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'lost completion', 1_000) + replayIntoTerminal(pane, ref, 'healthy completion', 60_000) + terminal.pendingCallbacks.shift() // drop only the first completion + vi.advanceTimersByTime(1_000) // first engagement's probe enqueued + + terminal.flush() // healthy completion + probe both parse + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('never probes after a normal completion', () => { + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'healthy', 1_000) + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + + vi.advanceTimersByTime(60_000) + expect(terminal.lastData).toEqual(['healthy']) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('resolves replayIntoTerminalAsync via the wedged path so restore chains cannot hang', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', 1_000) + let resolved = false + void replayDone.then(() => { + resolved = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + expect(resolved).toBe(true) + expect(isPaneReplaying(ref, 1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index e6c733d668c..6bf821ea62d 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -1,5 +1,6 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' // Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, // DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by @@ -27,55 +28,98 @@ import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-f export type ReplayingPanesRef = React.RefObject> -// Why: the guard normally releases in xterm's write-completion callback, but -// that callback never fires for a pane whose terminal has not been flushed — -// e.g. a cold-restore reattach that replays into a just-mounted / offscreen -// pane. Without a ceiling the counter leaks, isPaneReplaying() stays true, and -// the onData handler silently drops EVERY keystroke (the pane looks alive but -// ignores input). Release deterministically after this bound so the guard -// always clears; in the normal path onParsed fires within milliseconds and -// cancels it first. Tradeoff: if the callback is lost and the pane parses the -// replayed buffer only after this bound elapses (e.g. rendering resumes long -// after restore), xterm's auto-replies to any device queries in that buffer can -// leak to the shell as input. Accepted as strictly preferable to a permanent -// input lockout, and bounded by the ~100 KB replay cap. -const REPLAY_GUARD_RELEASE_FALLBACK_MS = 1000 +// Why stall handling exists: the decrement above only runs when xterm +// completes the write. A wedged WriteBuffer (sync throw escaping a parse +// handler or a write-completion callback — see +// xterm-write-buffer-stall.repro.test.ts) or a disposed-terminal race can +// drop that completion forever, leaving the guard latched on a live pane — +// which silently eats every keystroke (Discord #performance / issue #2836). +// +// Why release is probe-certified, never time-based: a blind timeout release +// while a slow replay is still parsing would let xterm's auto-replies leak +// into the shell — and into agent TUIs, where a leaked ESC reads as the user +// pressing Escape. Instead, when a completion looks overdue we enqueue an +// empty probe write. xterm parses writes in order, so only three states are +// possible, and release is provably safe in every state that releases: +// 1. probe completes, replay callback already ran → normal release won. +// 2. probe completes, replay callback never ran → every replay byte has +// parsed (FIFO), so no further auto-replies can exist; the completion +// was genuinely lost. Release. +// 3. probe never completes → the pipeline is +// wedged; a dead parser can never emit auto-replies, so releasing after +// a bounded wait cannot leak anything — and the pane needs recovery, +// which the breadcrumb reports. +// While the probe is pending (slow-but-alive replay), the guard HOLDS. +const REPLAY_GUARD_STALL_CHECK_MS = 10_000 export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { return (ref.current.get(paneId) ?? 0) > 0 } -/** Engage the per-pane replay guard and return a `finish` callback that - * releases it exactly once. The guard also auto-releases after - * REPLAY_GUARD_RELEASE_FALLBACK_MS so a missing xterm parse callback can never - * strand it engaged. `onReleased` runs once when the guard actually releases - * (via `finish` or the fallback), so async callers can settle either way. */ +type ReplayGuardWriteTarget = Pick + +/** + * Engage the replay counter for one write and return the release function. + * Release runs exactly once — from xterm's write completion or, failing + * that, from the probe-certified stall path — so a lost completion cannot + * latch the guard. + */ function engageReplayGuard( - replayingPanesRef: ReplayingPanesRef, + map: Map, paneId: number, - onReleased?: () => void + terminal: ReplayGuardWriteTarget, + stallCheckMs: number, + onRelease?: () => void ): () => void { - const map = replayingPanesRef.current map.set(paneId, (map.get(paneId) ?? 0) + 1) let released = false - const release = (): void => { + let timer: ReturnType | null = null + const release = (reason: 'parsed' | 'lost-completion' | 'wedged'): void => { if (released) { return } released = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } const remaining = (map.get(paneId) ?? 1) - 1 if (remaining <= 0) { map.delete(paneId) } else { map.set(paneId, remaining) } - onReleased?.() + if (reason === 'lost-completion') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write parsed but the replay completion never arrived (lost write callback)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_lost_completion', { paneId }) + } else if (reason === 'wedged') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write never parsed (wedged xterm write pipeline; pane likely needs recovery)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', { paneId }) + } + onRelease?.() } - const fallback = setTimeout(release, REPLAY_GUARD_RELEASE_FALLBACK_MS) - return () => { - clearTimeout(fallback) - release() + const probeForStall = (): void => { + if (released) { + return + } + try { + // FIFO certification: this callback can only run after every replay + // byte queued before it has parsed (state 2 above). + terminal.write('', () => release('lost-completion')) + } catch { + // write threw (terminal disposed mid-replay): nothing will ever parse, + // so no auto-replies can leak. + release('wedged') + return + } + timer = setTimeout(() => release('wedged'), stallCheckMs) } + timer = setTimeout(probeForStall, stallCheckMs) + return () => release('parsed') } /** Writes `data` into the pane's terminal with the replay guard engaged, @@ -85,38 +129,50 @@ function engageReplayGuard( export function replayIntoTerminal( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): void { if (!data) { return } - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id) + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs + ) // Why: hidden/snapshot replay bypasses the live foreground write path, but // WebGL/canvas renderers still need a post-parse repaint to drop stale cells. writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) } export function replayIntoTerminalAsync( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): Promise { if (!data) { return Promise.resolve() } return new Promise((resolve) => { - // Why: settle the promise when the guard releases — via parse completion or - // the fallback — so an awaiting caller never hangs if xterm's callback for a - // just-mounted/offscreen pane never arrives. - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id, resolve) + // Why resolve on either release path: callers await this to sequence + // restore steps; a lost write completion must not hang the restore chain. + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs, + resolve + ) writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) }) } diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts new file mode 100644 index 00000000000..7007995bd0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts @@ -0,0 +1,157 @@ +// Why: pins the stale-occlusion proof — user input while +// document.visibilityState claims 'hidden' must latch the override and run +// each pane's recovery exactly once, and a genuine visibilitychange must hand +// authority back to the occlusion tracker. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as StaleDocumentVisibility from './stale-document-visibility' + +type Handler = () => void + +type EventTargetStub = { + addEventListener: (type: string, handler: Handler, options?: unknown) => void + removeEventListener: (type: string, handler: Handler, options?: unknown) => void + emit: (type: string) => void + listenerCount: (type: string) => number +} + +function createEventTargetStub(): EventTargetStub { + const listeners = new Map>() + return { + addEventListener(type, handler) { + const set = listeners.get(type) ?? new Set() + set.add(handler) + listeners.set(type, set) + }, + removeEventListener(type, handler) { + listeners.get(type)?.delete(handler) + }, + emit(type) { + for (const handler of listeners.get(type) ?? []) { + handler() + } + }, + listenerCount(type) { + return listeners.get(type)?.size ?? 0 + } + } +} + +type DocumentStub = EventTargetStub & { visibilityState: string } + +describe('stale document visibility', () => { + const originalDocument = (globalThis as { document?: unknown }).document + const originalWindow = (globalThis as { window?: unknown }).window + let documentStub: DocumentStub + let windowStub: EventTargetStub + let warnSpy: ReturnType + let mod: typeof StaleDocumentVisibility + + beforeEach(async () => { + vi.resetModules() + documentStub = { ...createEventTargetStub(), visibilityState: 'visible' } + windowStub = createEventTargetStub() + ;(globalThis as { document: unknown }).document = documentStub + ;(globalThis as { window: unknown }).window = windowStub + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mod = await import('./stale-document-visibility') + }) + + afterEach(() => { + mod.resetStaleDocumentVisibilityForTesting() + warnSpy.mockRestore() + ;(globalThis as { document: unknown }).document = originalDocument + ;(globalThis as { window: unknown }).window = originalWindow + }) + + it('latches the override and runs recovery once when input arrives while hidden', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledTimes(1) + + // Further input during the same stuck episode must not re-run recovery. + documentStub.emit('keydown') + documentStub.emit('pointerdown') + expect(recovery).toHaveBeenCalledTimes(1) + }) + + it('does not latch while the document is genuinely visible', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + + documentStub.emit('keydown') + documentStub.emit('pointerdown') + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + expect(recovery).not.toHaveBeenCalled() + }) + + it('treats pointerdown and window focus as staleness proof too', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('pointerdown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + mod.resetStaleDocumentVisibilityForTesting() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + }) + + it('hands authority back to the occlusion tracker on a genuine visibilitychange', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + documentStub.visibilityState = 'visible' + documentStub.emit('visibilitychange') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + + // A fresh wedge after trust was restored must latch (and recover) again. + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(2) + }) + + it('keeps running other panes when one recovery listener throws', () => { + const throwing = vi.fn(() => { + throw new Error('pane exploded') + }) + const healthy = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(throwing) + mod.registerStaleDocumentVisibilityRecovery(healthy) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + }) + + it('removes global listeners when the last pane unregisters', () => { + const unregisterA = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + const unregisterB = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + + unregisterA() + expect(documentStub.listenerCount('keydown')).toBe(1) + unregisterB() + expect(documentStub.listenerCount('keydown')).toBe(0) + expect(documentStub.listenerCount('pointerdown')).toBe(0) + expect(documentStub.listenerCount('visibilitychange')).toBe(0) + expect(windowStub.listenerCount('focus')).toBe(0) + + // Re-registering must reinstall for a later pane. + mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts new file mode 100644 index 00000000000..8d54cbe94c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts @@ -0,0 +1,109 @@ +// Chromium's macOS window-occlusion tracker can wedge document.visibilityState +// at 'hidden' after display sleep, never firing another visibilitychange. Real +// user input reaching this window while the document claims hidden is a +// physical contradiction — keystrokes and clicks only reach a focused, +// on-screen window. This module latches that proof so terminal delivery can +// treat the document as visible until the next genuine visibilitychange +// restores trust in the occlusion tracker. Without it, the hidden-delivery +// gate stays latched for panes the user is looking at and main drops their +// bytes indefinitely (field: 78MB dropped on 2 visible ptys, v1.4.124-rc.2.perf). + +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +type StaleVisibilityRecoveryListener = () => void + +const recoveryListeners = new Set() +let visibilityProvenStale = false +let globalListenersInstalled = false + +export function isDocumentVisibilityProvenStale(): boolean { + return visibilityProvenStale +} + +function onUserInteractionWithDocument(): void { + if (visibilityProvenStale || document.visibilityState !== 'hidden') { + return + } + visibilityProvenStale = true + recordTerminalFreezeBreadcrumb('stale-visibility-latch', { + recoveryListenerCount: recoveryListeners.size + }) + console.warn( + '[terminal] user input arrived while document.visibilityState is hidden — treating occlusion state as stale and re-syncing terminal delivery', + { recoveryListenerCount: recoveryListeners.size } + ) + for (const listener of recoveryListeners) { + try { + listener() + } catch { + // Why: one pane's recovery failure must not starve the other panes'. + } + } +} + +function onDocumentVisibilityChange(): void { + recordTerminalFreezeBreadcrumb('visibilitychange', { + state: document.visibilityState, + clearedStaleOverride: visibilityProvenStale + }) + // A genuine visibilitychange means the occlusion tracker is reporting + // again — hand authority back to document.visibilityState. + visibilityProvenStale = false +} + +function installGlobalListeners(): void { + if ( + globalListenersInstalled || + typeof document === 'undefined' || + typeof window === 'undefined' || + typeof document.addEventListener !== 'function' + ) { + return + } + globalListenersInstalled = true + // Capture phase so no stopPropagation in the app can hide the proof; the + // handler is a single property read when visibility is healthy. + document.addEventListener('keydown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + document.addEventListener('pointerdown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + window.addEventListener('focus', onUserInteractionWithDocument) + document.addEventListener('visibilitychange', onDocumentVisibilityChange) +} + +function removeGlobalListeners(): void { + if (!globalListenersInstalled) { + return + } + globalListenersInstalled = false + document.removeEventListener('keydown', onUserInteractionWithDocument, { capture: true }) + document.removeEventListener('pointerdown', onUserInteractionWithDocument, { capture: true }) + window.removeEventListener('focus', onUserInteractionWithDocument) + document.removeEventListener('visibilitychange', onDocumentVisibilityChange) +} + +// The listener runs when staleness is first proven; register the same handler +// used for document visibilitychange so recovery reuses the pane's existing +// gate-resync + hidden-output-restore path. +export function registerStaleDocumentVisibilityRecovery( + listener: StaleVisibilityRecoveryListener +): () => void { + installGlobalListeners() + recoveryListeners.add(listener) + return () => { + recoveryListeners.delete(listener) + if (recoveryListeners.size === 0) { + removeGlobalListeners() + } + } +} + +export function resetStaleDocumentVisibilityForTesting(): void { + visibilityProvenStale = false + recoveryListeners.clear() + removeGlobalListeners() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..b7e088ff4aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' -import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' import { + applyTerminalAppearance, hexToRgba, installMode2031Handlers, maybePushMode2031Flip, - mode2031SequenceFor + mode2031SequenceFor, + publishTerminalViewAttributesAtAppStart } from './terminal-appearance' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean @@ -373,6 +378,133 @@ describe('installMode2031Handlers', () => { }) }) +describe('applyTerminalAppearance theme assignment', () => { + // xterm's OptionsService fires the theme change on object IDENTITY, and + // ThemeService._setTheme then rebuilds the palette, discarding OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding, + // zoom) compose a fresh-but-value-identical theme; assigning it anyway + // wipes TUI color mutations on visible panes while the deduped publisher + // keeps hidden overlays — so the assignment must be value-gated. + function makePane(id: number): ManagedPane { + return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function apply(pane: ManagedPane, settings: ReturnType): void { + applyTerminalAppearance( + makeManager([pane]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + } + + it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + expect(firstTheme).toBeDefined() + + apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 }) + + // Identity-stable theme means xterm never re-runs _setTheme, so a TUI's + // modifyColors mutation survives the font tweak. + expect(pane.terminal.options.theme).toBe(firstTheme) + expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2) + }) + + it('still assigns a fresh theme when composed values actually change', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + + apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } }) + + expect(pane.terminal.options.theme).not.toBe(firstTheme) + expect(pane.terminal.options.theme?.background).toBe('#102030') + }) +}) + +describe('publishTerminalViewAttributesAtAppStart', () => { + // Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch + // PTYs can query OSC 10/11 before any terminal pane mounts; the app-start + // publication must go out with no pane manager involved at all. + it('publishes composed attributes without any pane mount and dedupes repeats', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = getDefaultSettings('/tmp') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(sent).toHaveLength(1) + expect(sent[0]!.ansi).toHaveLength(256) + expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false) + expect(sent).toHaveLength(1) + }) + + it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => { + _resetTerminalViewAttributesPublisherForTest() + const publishMock = vi.fn() + ;(globalThis as unknown as { window: unknown }).window = { + api: { pty: { publishTerminalViewAttributes: publishMock } } + } + try { + const settings = getDefaultSettings('/tmp') + publishTerminalViewAttributesAtAppStart(settings, true) + expect(publishMock).toHaveBeenCalledTimes(1) + + // The first pane mount composes the identical app-global snapshot, so + // the publisher dedupe keeps it a single push. + const manager = { + getPanes: () => [], + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + applyTerminalAppearance( + manager, + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publishMock).toHaveBeenCalledTimes(1) + } finally { + delete (globalThis as { window?: unknown }).window + _resetTerminalViewAttributesPublisherForTest() + } + }) + + it('publishes nothing before settings are loaded', () => { + _resetTerminalViewAttributesPublisherForTest() + const send = vi.fn(() => true) + expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false) + expect(send).not.toHaveBeenCalled() + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 2df2f261506..a34ac215e66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,7 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' +import { guardParserHandler } from './terminal-parser-handler-guard' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, @@ -20,6 +21,8 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' export { mode2031SequenceFor } @@ -56,50 +59,56 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ // continue processing the same sequence, so compound sequences like // `CSI ?25;2031h` still update cursor visibility correctly. return [ - deps.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => { - if (hasMode2031(params)) { - // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by - // the previous session's TUI (e.g. Claude Code). Replaying that - // buffer runs this handler, and without the guard we'd push - // `CSI ?997;1n` via transport.sendInput into a fresh shell that has - // no TUI consuming it — zsh then echoes the literal escape sequence - // onto the prompt. The replay guard in pty-connection.ts only covers - // xterm's own onData auto-replies, not handler-triggered sends, so - // gate explicitly here. We also skip recording the subscribe bit: - // the fresh shell is not actually subscribed, so a later theme flip - // must not push either. A real TUI that starts up after restore will - // re-emit `?2031h` itself and register normally. - // - // Why this broad guard is safe across all replay sources: the only - // replay path that can carry raw `?2031h` is cold-restore scrollback - // (pty-connection.ts), which is disk-replayed PTY output against a - // fresh shell — the case this guard targets. Daemon snapshot payloads - // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted - // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: - // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ - // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences - // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever - // adds ?2031 to that whitelist, this guard would start suppressing - // legitimate subscribes during snapshot reattach — revisit then. - if (deps.isReplaying()) { - return false + deps.parser.registerCsiHandler( + { prefix: '?', final: 'h' }, + guardParserHandler('csi-mode2031-subscribe', (params) => { + if (hasMode2031(params)) { + // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by + // the previous session's TUI (e.g. Claude Code). Replaying that + // buffer runs this handler, and without the guard we'd push + // `CSI ?997;1n` via transport.sendInput into a fresh shell that has + // no TUI consuming it — zsh then echoes the literal escape sequence + // onto the prompt. The replay guard in pty-connection.ts only covers + // xterm's own onData auto-replies, not handler-triggered sends, so + // gate explicitly here. We also skip recording the subscribe bit: + // the fresh shell is not actually subscribed, so a later theme flip + // must not push either. A real TUI that starts up after restore will + // re-emit `?2031h` itself and register normally. + // + // Why this broad guard is safe across all replay sources: the only + // replay path that can carry raw `?2031h` is cold-restore scrollback + // (pty-connection.ts), which is disk-replayed PTY output against a + // fresh shell — the case this guard targets. Daemon snapshot payloads + // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted + // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: + // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ + // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences + // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever + // adds ?2031 to that whitelist, this guard would start suppressing + // legitimate subscribes during snapshot reattach — revisit then. + if (deps.isReplaying()) { + return false + } + deps.paneMode2031.set(deps.paneId, true) + deps.onSubscribe() } - deps.paneMode2031.set(deps.paneId, true) - deps.onSubscribe() - } - return false - }), + return false + }) + ), // Why no replay guard on the unsubscribe branch: clearing stale bookkeeping // is harmless. We only push CSI 997 on subscribe, never on unsubscribe, so // even if a cold-restore replay carries `?2031l`, this handler just deletes // map entries that a later real `?2031h` will re-populate normally. - deps.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (params) => { - if (hasMode2031(params)) { - deps.paneMode2031.delete(deps.paneId) - deps.paneLastThemeMode.delete(deps.paneId) - } - return false - }) + deps.parser.registerCsiHandler( + { prefix: '?', final: 'l' }, + guardParserHandler('csi-mode2031-unsubscribe', (params) => { + if (hasMode2031(params)) { + deps.paneMode2031.delete(deps.paneId) + deps.paneLastThemeMode.delete(deps.paneId) + } + return false + }) + ) ] } @@ -200,6 +209,54 @@ export function composeActiveTerminalTheme( return theme } +/** App-start publication (terminal-query-authority.md §Phase 6 + * prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any + * terminal pane mounts, and main's responder is silent-until-first-push. + * Composes the same theme applyTerminalAppearance would and publishes it + * through the same deduped publisher, so the later pane-mount apply is a + * no-op re-push. Returns whether a publish actually went out. */ +export function publishTerminalViewAttributesAtAppStart( + settings: GlobalSettings | null | undefined, + systemPrefersDark: boolean, + send?: (attributes: TerminalViewAttributes) => boolean +): boolean { + if (!settings) { + return false + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const theme = composeActiveTerminalTheme(baseTheme, settings) + return send !== undefined + ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) + : publishTerminalViewAttributes(theme, appearance.mode, settings) +} + +// Value equality over composed ITheme objects (flat string slots plus the +// extendedAnsi string array), used to gate the per-pane options.theme write. +function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { + if (!a) { + return false + } + if (a === b) { + return true + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (key === 'extendedAnsi') { + continue + } + if (a[key as keyof ITheme] !== b[key as keyof ITheme]) { + return false + } + } + const extA = a.extendedAnsi + const extB = b.extendedAnsi + if (!extA || !extB) { + return extA === extB + } + return extA.length === extB.length && extA.every((value, i) => value === extB[i]) +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -214,6 +271,11 @@ export function applyTerminalAppearance( const paneStyles = resolvePaneStyleOptions(settings) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const theme = composeActiveTerminalTheme(baseTheme, settings) + // View-attribute bridge (Phase 5 slice 2): this is the single point where + // the composed app-global terminal appearance exists, so publish it to + // main's hidden-PTY query responder here. Deduped inside the publisher — + // per-pane re-applies and attribute-neutral tweaks do not re-push. + publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) @@ -223,7 +285,14 @@ export function applyTerminalAppearance( ) for (const pane of manager.getPanes()) { - if (theme) { + // Why value-gated: xterm's OptionsService fires on object identity, and + // ThemeService._setTheme rebuilds the palette, discarding TUI OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family, + // line height, padding, per-pane zoom) compose a fresh-but-identical + // theme; skipping the write keeps visible-pane mutations alive (a + // pre-existing loss this also fixes) and matches the hidden responder's + // deduped overlay behavior, so hidden and visible no longer drift. + if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } // Why: xterm's allowTransparency has measurable rendering cost, so clear diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts index 8308fc8b210..37353c7727c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts @@ -5,6 +5,7 @@ import { terminalOscColorQuerySlotsForBody, type TerminalOscColorQuerySlot } from '../../../../shared/terminal-osc-color-reply' +import { guardParserHandler } from './terminal-parser-handler-guard' export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c' export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' @@ -118,37 +119,46 @@ export function installTerminalCapabilityReplyHandlers( deps: TerminalCapabilityRepliesDeps ): IDisposable { const disposables = [ - deps.parser.registerCsiHandler({ final: 'c' }, (params) => { - if (!isPrimaryDeviceAttributesQuery(params)) { - return false - } - // Why: restored scrollback may contain old DA1 queries; answering those - // into the fresh shell recreates the stray-input leak this handler fixes. - if (!deps.isReplaying()) { - deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) - } - return true - }), - deps.parser.registerOscHandler(10, (data) => { - const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { + deps.parser.registerCsiHandler( + { final: 'c' }, + guardParserHandler('csi-da1', (params) => { + if (!isPrimaryDeviceAttributesQuery(params)) { + return false + } + // Why: restored scrollback may contain old DA1 queries; answering those + // into the fresh shell recreates the stray-input leak this handler fixes. + if (!deps.isReplaying()) { + deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) + } return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }), - deps.parser.registerOscHandler(11, (data) => { - const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { - return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }) + }) + ), + deps.parser.registerOscHandler( + 10, + guardParserHandler('osc-10-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ), + deps.parser.registerOscHandler( + 11, + guardParserHandler('osc-11-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ) ] return { diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 41873fa4fd9..61ad0891fa6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -1,4 +1,5 @@ import type { Terminal, IDisposable } from '@xterm/xterm' +import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' type TerminalCommandLifecycleOptions = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -6,100 +7,31 @@ type TerminalCommandLifecycleOptions = { onCommandStarted?: () => void } -type OscTerminator = { - index: number - length: number -} - -const OSC_133_PREFIX = '\x1b]133;' -const MAX_OSC_CARRY_LENGTH = 4096 - -function findOscTerminator(data: string, startIndex: number): OscTerminator | null { - const bel = data.indexOf('\x07', startIndex) - const st = data.indexOf('\x1b\\', startIndex) - - if (bel === -1 && st === -1) { - return null - } - if (bel !== -1 && (st === -1 || bel < st)) { - return { index: bel, length: 1 } - } - return { index: st, length: 2 } -} - -function parseBestEffortExitCode(value: string | undefined): number | null { - if (!value) { - return null - } - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed -} - -function findPrefixCarry(data: string): string { - const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) - for (let length = maxCarryLength; length > 0; length -= 1) { - const suffix = data.slice(data.length - length) - if (OSC_133_PREFIX.startsWith(suffix)) { - return suffix - } - } - return '' -} - export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): { handlePtyData: (data: string) => void attachXtermConsumer: (terminal: Terminal) => IDisposable dispose: () => void } { - let carry = '' + // Why: the byte parsing lives in shared so main's side-effect tracker emits + // identical command-finished facts for local/SSH PTYs; this renderer wrapper + // remains the byte path for remote-runtime PTYs and the kill-switch-off mode. + const scanner = createOsc133CommandFinishedScanner( + options.onCommandFinished, + options.onCommandStarted + ) const disposables: IDisposable[] = [] - const handleOsc133 = (payload: string): void => { - const [sequence, exitCode] = payload.split(';') - if (sequence === 'C') { - options.onCommandStarted?.() - return - } - if (sequence === 'D') { - options.onCommandFinished(parseBestEffortExitCode(exitCode)) - } - } - - const handlePtyData = (data: string): void => { - let combined = carry + data - carry = '' - - while (combined.length > 0) { - const start = combined.indexOf(OSC_133_PREFIX) - if (start === -1) { - carry = findPrefixCarry(combined) - return - } - - const payloadStart = start + OSC_133_PREFIX.length - const terminator = findOscTerminator(combined, payloadStart) - if (!terminator) { - carry = combined.slice(start) - if (carry.length > MAX_OSC_CARRY_LENGTH) { - carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) - } - return - } - - handleOsc133(combined.slice(payloadStart, terminator.index)) - combined = combined.slice(terminator.index + terminator.length) - } - } - return { - handlePtyData, + handlePtyData: scanner.scan, attachXtermConsumer(terminal) { + // Why: swallow OSC 133 so shell-integration markers never paint — + // rendering hygiene that applies regardless of side-effect authority. const disposable = terminal.parser.registerOscHandler(133, () => true) disposables.push(disposable) return disposable }, dispose() { - carry = '' + scanner.reset() for (const disposable of disposables.splice(0)) { disposable.dispose() } diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts new file mode 100644 index 00000000000..8db2051fa35 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts @@ -0,0 +1,201 @@ +// Why: pins the renderer half of the dead-push-delivery recovery — the +// watchdog must cost nothing while output flows, confirm a wedge across two +// silent ticks against main's invoke-reported state, then heal exactly once +// per cooldown: re-attach push listeners, request the write-off, and route +// the pulled restore markers to pane handlers locally. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +const INTERVAL_MS = 15_000 + +const HEALTHY: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: 1_000 +} + +const STALLED: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 2, + msSinceLastAck: null +} + +describe('terminal delivery watchdog', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const reportMock = vi.fn<(args: unknown) => Promise>() + const listenerCountMock = vi.fn(() => 1) + const reattachMock = vi.fn() + let warnSpy: ReturnType + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + reportMock.mockReset() + listenerCountMock.mockClear() + reattachMock.mockClear() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: listenerCountMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + async function startWatchdog(): Promise<{ + recordPtyDataReceived: (ptyId: string, chars: number) => void + registerRestoreHandler: ( + ptyId: string, + handler: (event: { id: string; reason: string; markerSeq?: number }) => void + ) => void + }> { + const watchdog = await import('./terminal-delivery-watchdog') + const restoreChannel = await import('./pty-model-restore-channel') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + return { + recordPtyDataReceived: watchdog.recordPtyDataReceived, + registerRestoreHandler: (ptyId, handler) => { + restoreChannel.registerPtyModelRestoreNeededHandler(ptyId, handler) + } + } + } + + it('does zero IPC while pty output is flowing', async () => { + const { recordPtyDataReceived } = await startWatchdog() + + for (let tick = 0; tick < 8; tick++) { + recordPtyDataReceived('pty-1', 64) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('reports during silence but never heals a healthy-idle main', async () => { + reportMock.mockResolvedValue(HEALTHY) + await startWatchdog() + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 4) + + expect(reportMock).toHaveBeenCalledTimes(4) + for (const call of reportMock.mock.calls) { + expect((call[0] as { heal?: boolean }).heal).toBeUndefined() + } + expect(reattachMock).not.toHaveBeenCalled() + }) + + it('confirms a wedge across two silent ticks, re-attaches, and routes pulled restore markers', async () => { + reportMock.mockImplementation((args) => + Promise.resolve( + (args as { heal?: boolean }).heal + ? { + ...STALLED, + inFlightTotalChars: 0, + inFlightPtyCount: 0, + writtenOff: [{ id: 'pty-1', markerSeq: 42, writtenOffChars: 512 * 1024 }] + } + : STALLED + ) + ) + const { recordPtyDataReceived, registerRestoreHandler } = await startWatchdog() + const restoreEvents: { id: string; reason: string; markerSeq?: number }[] = [] + registerRestoreHandler('pty-1', (event) => restoreEvents.push(event)) + + // Bytes flowed once, then the push channel died: the field shape. + recordPtyDataReceived('pty-1', 128) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).not.toHaveBeenCalled() + + // First silent tick: report only, no heal yet. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).toHaveBeenCalledTimes(1) + expect(reattachMock).not.toHaveBeenCalled() + + // Second silent tick confirms: re-attach precedes the heal report, the + // listener count is captured for field discrimination, and the pulled + // marker reaches the pane handler without any push event. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reattachMock).toHaveBeenCalledTimes(1) + const healCalls = reportMock.mock.calls.filter( + (call) => (call[0] as { heal?: boolean }).heal === true + ) + expect(healCalls).toHaveLength(1) + expect(healCalls[0]![0]).toMatchObject({ + heal: true, + rendererPtyDataListenerCount: 1, + receivedCharsByPty: { 'pty-1': 128 } + }) + expect(restoreEvents).toEqual([{ id: 'pty-1', reason: 'delivery-heal', markerSeq: 42 }]) + }) + + it('rate-limits heals to the cooldown while the wedge persists', async () => { + reportMock.mockImplementation((args) => + Promise.resolve((args as { heal?: boolean }).heal ? { ...STALLED, writtenOff: [] } : STALLED) + ) + await startWatchdog() + + const countHeals = (): number => + reportMock.mock.calls.filter((call) => (call[0] as { heal?: boolean }).heal === true).length + + // Ticks at 15s/30s: confirm + first heal. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2) + expect(countHeals()).toBe(1) + + // 45s/60s/75s: streak rebuilds but the 60s cooldown holds. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + expect(countHeals()).toBe(1) + + // 90s: cooldown elapsed — one more heal, not one per tick. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(countHeals()).toBe(2) + expect(reattachMock).toHaveBeenCalledTimes(2) + }) + + it('stays off when no PTY expects push delivery', async () => { + reportMock.mockResolvedValue(STALLED) + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => false + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('never starts without the invoke heal lane (web client, partial mocks)', async () => { + ;(window.api.pty as { reportRendererDeliveryState?: unknown }).reportRendererDeliveryState = + undefined + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reattachMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts new file mode 100644 index 00000000000..c9b0d8c29e5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts @@ -0,0 +1,278 @@ +/** + * Renderer-initiated watchdog for dead main→renderer push delivery. + * + * Field evidence (v1.4.121-rc.0 snapshot, 2026-07-06): all `pty:data` push + * events stop reaching the renderer (a 245-char shell prompt sat un-ACKed; + * every terminal blank) while invoke IPC keeps working — the wedged window + * answered `getRendererDeliveryDebugSnapshot` live. The prior recovery layers + * cannot reach that state: the xterm write-pipeline guards and replay-guard + * release (#7150) run only after bytes arrive, and the cumulative-ACK + + * solicited-resync protocol heals lost ACKs but probes over the same push + * channel that is dead (upstream precedent: electron#37067, one-directional + * Mojo IPC death). This watchdog is the missing lane: it detects the wedge + * and heals over invoke — the direction proven alive — with zero cost on the + * data hot path (one Map upsert per received chunk; a tick does no IPC while + * output flows or while no PTY delivery is expected). + */ +import { e2eConfig } from '@/lib/e2e-config' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { deliverPulledPtyModelRestoreMarkers } from './pty-model-restore-channel' +import { getProcessedPtyCharTotals } from './terminal-pty-ack-gate' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const WATCHDOG_INTERVAL_MS = 15_000 +// Why 2 ticks: one silent interval can be a probe racing an in-transit chunk; +// two full intervals with zero received events while main reports ACK-starved +// in-flight bytes only occurs in the wedged state. +const WATCHDOG_STALL_TICKS_TO_HEAL = 2 +// Why: a heal that could not revive the push channel must not repaint-storm; +// pull-restores repeat at most once per cooldown while the wedge persists. +const WATCHDOG_HEAL_COOLDOWN_MS = 60_000 + +type TerminalDeliveryWatchdogConfig = { + intervalMs: number + stallTicksToHeal: number + healCooldownMs: number +} + +type TerminalDeliveryWatchdogDeps = { + /** Detach and re-subscribe the dispatcher's push-channel listeners. */ + reattachPushListeners: () => void + /** True while any PTY handler or eager buffer expects push delivery. */ + hasAttachedPtys: () => boolean +} + +const receivedPtyCharTotals = new Map() +let receivedPtyDataEventCount = 0 +let blackholePtyPushDelivery = false + +let watchdogDeps: TerminalDeliveryWatchdogDeps | null = null +let watchdogTimer: ReturnType | null = null +let watchdogConfig: TerminalDeliveryWatchdogConfig = { + intervalMs: WATCHDOG_INTERVAL_MS, + stallTicksToHeal: WATCHDOG_STALL_TICKS_TO_HEAL, + healCooldownMs: WATCHDOG_HEAL_COOLDOWN_MS +} +let eventCountAtLastTick = 0 +let stallStreakTicks = 0 +let lastHealAtMs: number | null = null +let healCount = 0 +let tickInFlight = false + +/** One Map upsert per received chunk — the watchdog's only hot-path cost. + * Counted at dispatcher enqueue, BEFORE parse-deferred ACK crediting, so + * main can tell "lost in the channel" from "received, parse-pending". */ +export function recordPtyDataReceived(ptyId: string, chars: number): void { + receivedPtyDataEventCount += 1 + receivedPtyCharTotals.set(ptyId, (receivedPtyCharTotals.get(ptyId) ?? 0) + chars) +} + +export function clearReceivedPtyCharTotal(ptyId: string): void { + receivedPtyCharTotals.delete(ptyId) +} + +/** E2e blackhole: simulates the field wedge (push events vanish before the + * dispatcher sees them, no receive count, no ACK). Never true in prod. */ +export function isPtyPushDeliveryBlackholed(): boolean { + return blackholePtyPushDelivery +} + +function isMainDeliveryStalled(health: PtyRendererDeliveryHealthReply): boolean { + // Why msSinceLastAck may be null: a wedged-from-first-byte session (the + // field case's brand-new terminal) never ACKs; in-flight debt alone is the + // signal then. A recent ACK means some pty still round-trips — not a wedge. + return ( + health.inFlightTotalChars > 0 && + (health.msSinceLastAck === null || health.msSinceLastAck >= watchdogConfig.intervalMs) + ) +} + +async function runWatchdogTick(): Promise { + const deps = watchdogDeps + const report = window.api?.pty?.reportRendererDeliveryState + if (!deps || typeof report !== 'function') { + stopTerminalDeliveryWatchdog() + return + } + if (receivedPtyDataEventCount !== eventCountAtLastTick) { + eventCountAtLastTick = receivedPtyDataEventCount + stallStreakTicks = 0 + return + } + if (!deps.hasAttachedPtys()) { + stallStreakTicks = 0 + return + } + const health = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals() + }) + if (!health || !isMainDeliveryStalled(health)) { + stallStreakTicks = 0 + return + } + stallStreakTicks += 1 + recordTerminalFreezeBreadcrumb('watchdog-stall', { + stallStreakTicks, + inFlightTotalChars: health.inFlightTotalChars, + msSinceLastAck: health.msSinceLastAck + }) + if (stallStreakTicks < watchdogConfig.stallTicksToHeal) { + return + } + if (lastHealAtMs !== null && Date.now() - lastHealAtMs < watchdogConfig.healCooldownMs) { + return + } + await healDeadPushDelivery(deps, report, health) +} + +async function healDeadPushDelivery( + deps: TerminalDeliveryWatchdogDeps, + report: NonNullable, + stalled: PtyRendererDeliveryHealthReply +): Promise { + lastHealAtMs = Date.now() + stallStreakTicks = 0 + healCount += 1 + // Why read BEFORE re-attach: 0 here = the listener was detached (app-level + // bug to hunt); ≥1 = events are being dropped below the emitter (channel + // dead, platform-level). The single most valuable field discriminator. + const listenerCountBeforeReattach = window.api?.pty?.getPtyDataListenerCount?.() ?? null + deps.reattachPushListeners() + const healed = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals(), + heal: true, + rendererPtyDataListenerCount: listenerCountBeforeReattach + }) + const writtenOff = healed?.writtenOff ?? [] + if (writtenOff.length > 0) { + deliverPulledPtyModelRestoreMarkers( + writtenOff.map((entry) => ({ + id: entry.id, + reason: 'delivery-heal' as const, + ...(typeof entry.markerSeq === 'number' ? { markerSeq: entry.markerSeq } : {}) + })) + ) + } + recordTerminalFreezeBreadcrumb('watchdog-heal', { + listenerCountBeforeReattach, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0) + }) + console.warn('[terminal] delivery watchdog healed dead push delivery', { + listenerCountBeforeReattach, + stalledInFlightChars: stalled.inFlightTotalChars, + stalledPtyCount: stalled.inFlightPtyCount, + msSinceLastAck: stalled.msSinceLastAck, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0), + healCount + }) +} + +function scheduleWatchdogTimer(): void { + if (watchdogTimer) { + clearInterval(watchdogTimer) + } + watchdogTimer = setInterval(() => { + // Why serialized: a heal awaits two invokes; overlapping ticks could + // double-heal inside one cooldown window. + if (tickInFlight) { + return + } + tickInFlight = true + void runWatchdogTick().finally(() => { + tickInFlight = false + }) + }, watchdogConfig.intervalMs) +} + +export function startTerminalDeliveryWatchdog(deps: TerminalDeliveryWatchdogDeps): void { + if (watchdogDeps) { + return + } + // Why gated on the invoke fn: the web remote client and unit tests expose a + // partial pty API; without the report lane the watchdog has no safe heal + // path and must stay off. + if (typeof window.api?.pty?.reportRendererDeliveryState !== 'function') { + return + } + watchdogDeps = deps + eventCountAtLastTick = receivedPtyDataEventCount + scheduleWatchdogTimer() + exposeE2eTerminalDeliveryWatchdog() +} + +export function stopTerminalDeliveryWatchdog(): void { + watchdogDeps = null + if (watchdogTimer) { + clearInterval(watchdogTimer) + watchdogTimer = null + } +} + +/** Prod-reachable state for the one-paste freeze report (ids redacted). */ +export function getTerminalDeliveryWatchdogDiagnostics(): { + running: boolean + receivedPtyDataEventCount: number + receivedCharsByPty: Record + stallStreakTicks: number + healCount: number + msSinceLastHeal: number | null +} { + const receivedCharsByPty: Record = {} + for (const [id, chars] of receivedPtyCharTotals) { + receivedCharsByPty[redactPtyIdForDiagnostics(id)] = chars + } + return { + running: watchdogTimer !== null, + receivedPtyDataEventCount, + receivedCharsByPty, + stallStreakTicks, + healCount, + msSinceLastHeal: lastHealAtMs === null ? null : Date.now() - lastHealAtMs + } +} + +// ─── E2e control surface ───────────────────────────────────────────── + +type E2eTerminalDeliveryWatchdogApi = { + blackhole: (on: boolean) => void + configure: (config: Partial) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } +} + +type E2eTerminalDeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: E2eTerminalDeliveryWatchdogApi +} + +function exposeE2eTerminalDeliveryWatchdog(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eTerminalDeliveryWatchdogWindow + target.__terminalDeliveryWatchdog ??= { + blackhole: (on) => { + blackholePtyPushDelivery = on + }, + configure: (config) => { + watchdogConfig = { ...watchdogConfig, ...config } + if (watchdogDeps) { + scheduleWatchdogTimer() + } + }, + snapshot: () => ({ + receivedPtyDataEventCount, + stallStreakTicks, + healCount, + blackholed: blackholePtyPushDelivery + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts new file mode 100644 index 00000000000..042d261450f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts @@ -0,0 +1,34 @@ +// Renderer half of the one-paste freeze report: a bounded ring of the +// delivery-affecting transitions (gate marks, visibility trust changes, +// watchdog heals, restore markers) so a field report carries the history +// that led to the frozen state, not just a point-in-time counter snapshot. +import { + type PtyDeliveryBreadcrumb, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics' + +const rendererDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() + +export function recordTerminalFreezeBreadcrumb( + kind: string, + detail?: PtyDeliveryBreadcrumb['detail'] +): void { + rendererDeliveryBreadcrumbs.record(kind, detail) +} + +// Why: lib-layer WebGL code (pane-webgl-renderer, the atlas registry) can't +// import this components-layer ring directly, so it records through a shared +// sink. Point that sink at the same ring here so context-loss and atlas-reset +// crumbs land in the one-paste report alongside delivery/visibility history. +setTerminalWebglDiagnosticRecorder((kind, detail) => + rendererDeliveryBreadcrumbs.record(kind, detail) +) + +export function getTerminalFreezeBreadcrumbs(): PtyDeliveryBreadcrumb[] { + return rendererDeliveryBreadcrumbs.snapshot() +} + +export function resetTerminalFreezeBreadcrumbsForTesting(): void { + rendererDeliveryBreadcrumbs.reset() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts new file mode 100644 index 00000000000..dd0cbc98c0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts @@ -0,0 +1,79 @@ +// Why: pins the one-paste freeze report contract — a single console call must +// return renderer state + main snapshot + breadcrumbs, and a dead/throwing +// invoke channel must be captured as data instead of sinking the report. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal freeze report', () => { + const originalWindow = (globalThis as { window?: unknown }).window + const originalDocument = (globalThis as { document?: unknown }).document + const snapshotMock = vi.fn() + const listenerCountMock = vi.fn(() => 1) + + beforeEach(() => { + vi.resetModules() + snapshotMock.mockReset() + listenerCountMock.mockClear() + ;(globalThis as { document: unknown }).document = { + visibilityState: 'hidden', + hasFocus: () => true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + ;(globalThis as { window: unknown }).window = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + pty: { + getRendererDeliveryDebugSnapshot: snapshotMock, + getPtyDataListenerCount: listenerCountMock + } + } + } + }) + + afterEach(() => { + ;(globalThis as { window: unknown }).window = originalWindow + ;(globalThis as { document: unknown }).document = originalDocument + }) + + it('assembles renderer state, breadcrumbs, and the main snapshot into one blob', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0, diagnostics: { perPty: [] } }) + const breadcrumbs = await import('./terminal-freeze-breadcrumbs') + breadcrumbs.recordTerminalFreezeBreadcrumb('gate-mark', { id: '…@@abc' }) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.renderer.documentVisibilityState).toBe('hidden') + expect(report.renderer.documentHasFocus).toBe(true) + expect(report.renderer.ptyDataListenerCount).toBe(1) + expect(report.renderer.breadcrumbs.map((crumb) => crumb.kind)).toContain('gate-mark') + expect(report.renderer.watchdog).toMatchObject({ running: false, healCount: 0 }) + expect(report.main).toMatchObject({ pendingPtyCount: 0 }) + expect(typeof report.capturedAt).toBe('string') + }) + + it('captures a failing invoke channel as data instead of throwing', async () => { + snapshotMock.mockRejectedValue(new Error('invoke dead')) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.main).toMatchObject({ snapshotError: expect.stringContaining('invoke dead') }) + }) + + it('installs the console-callable global exactly once per renderer', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0 }) + const { installTerminalFreezeReport, buildTerminalFreezeReport } = + await import('./terminal-freeze-report') + installTerminalFreezeReport() + const installed = ( + globalThis.window as unknown as { + __orcaTerminalFreezeReport?: () => Promise + } + ).__orcaTerminalFreezeReport + expect(installed).toBe(buildTerminalFreezeReport) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts new file mode 100644 index 00000000000..5c087827f9f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts @@ -0,0 +1,59 @@ +// One-paste freeze report: `await window.__orcaTerminalFreezeReport()` in the +// DevTools console of a frozen window returns renderer state, main state (with +// per-pty delivery table), and both processes' breadcrumb history in a single +// JSON blob. Assembled over invoke IPC — the direction proven alive in every +// field wedge observed — and installed in PROD builds, because the whole point +// is that an affected user never has to gather logs piecemeal again. +import { getTerminalFreezeBreadcrumbs } from './terminal-freeze-breadcrumbs' +import { getTerminalDeliveryWatchdogDiagnostics } from './terminal-delivery-watchdog' +import { isDocumentVisibilityProvenStale } from './stale-document-visibility' +import { getAllPaneRenderingDiagnostics } from '@/lib/pane-manager/pane-manager-registry' + +export type TerminalFreezeReport = { + capturedAt: string + renderer: { + documentVisibilityState: string | null + documentHasFocus: boolean | null + documentVisibilityProvenStale: boolean + ptyDataListenerCount: number | null + watchdog: ReturnType + // Why: per-pane WebGL state distinguishes a stale post-wake surface from a + // context-loss fallback — the missing signal for the garble-after-sleep class. + paneRendering: ReturnType + breadcrumbs: ReturnType + } + main: unknown +} + +export async function buildTerminalFreezeReport(): Promise { + const hasDocument = typeof document !== 'undefined' + // The main section must never sink the whole report — a dead invoke channel + // is itself a diagnostic worth capturing. + const main = await window.api?.pty + ?.getRendererDeliveryDebugSnapshot?.() + .catch((error: unknown) => ({ snapshotError: String(error) })) + return { + capturedAt: new Date().toISOString(), + renderer: { + documentVisibilityState: hasDocument ? document.visibilityState : null, + documentHasFocus: hasDocument ? document.hasFocus() : null, + documentVisibilityProvenStale: isDocumentVisibilityProvenStale(), + ptyDataListenerCount: window.api?.pty?.getPtyDataListenerCount?.() ?? null, + watchdog: getTerminalDeliveryWatchdogDiagnostics(), + paneRendering: getAllPaneRenderingDiagnostics(), + breadcrumbs: getTerminalFreezeBreadcrumbs() + }, + main: main ?? null + } +} + +type TerminalFreezeReportWindow = Window & { + __orcaTerminalFreezeReport?: () => Promise +} + +export function installTerminalFreezeReport(): void { + if (typeof window === 'undefined') { + return + } + ;(window as TerminalFreezeReportWindow).__orcaTerminalFreezeReport = buildTerminalFreezeReport +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts new file mode 100644 index 00000000000..8123b83a9e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -0,0 +1,45 @@ +/** + * Renderer-side predicate for main's Phase-4 hidden PTY delivery gate. + * + * The gate only operates when main holds side-effect authority for the PTY + * (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill + * switch is on. Callers decide once at pane/watcher creation — the decision + * picks which mode-2031 responder is registered (byte sidecar vs fact reply), + * so it must never flip per chunk. + */ +import type { GlobalSettings } from '../../../../shared/types' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedGateFlagCache: boolean | null | undefined + +function readPersistedHiddenDeliveryGateFlagSync(): boolean | null { + if (persistedGateFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedGateFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null + } catch { + persistedGateFlagCache = null + } + } + return persistedGateFlagCache +} + +export function isRendererHiddenPtyDeliveryGateEnabled( + settings: Pick | null +): boolean { + if (settings !== null) { + return settings.terminalHiddenDeliveryGate !== false + } + // Why: settings hydrate asynchronously; a pane/watcher bound before + // hydration must honor the persisted kill switch — the responder-mode + // decision made here is never revisited (same rationale as the + // side-effect-authority sync read). + return readPersistedHiddenDeliveryGateFlagSync() !== false +} + +/** Test seam: reset the persisted-flag cache between tests. */ +export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void { + persistedGateFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts new file mode 100644 index 00000000000..db76af7ec56 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' + +describe('hidden terminal restore scrollback', () => { + it('preserves configured history depth through the supported maximum', () => { + expect(resolveHiddenRestoreScrollbackRows(10_000)).toBe(10_000) + expect(resolveHiddenRestoreScrollbackRows(50_000)).toBe(50_000) + }) + + it('clamps malformed or oversized values with the shared desktop policy', () => { + expect(resolveHiddenRestoreScrollbackRows(undefined)).toBe(5_000) + expect(resolveHiddenRestoreScrollbackRows(100_000)).toBe(50_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts new file mode 100644 index 00000000000..6767d3f55f3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts @@ -0,0 +1,13 @@ +import { + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + normalizeDesktopTerminalSnapshotRows +} from '../../../../shared/terminal-scrollback-policy' + +/** Hidden view rebuilds must preserve the same history depth the live xterm + * retained; otherwise switching tabs silently changes the user's scrollback. */ +export function resolveHiddenRestoreScrollbackRows(configuredScrollback: unknown): number { + return ( + normalizeDesktopTerminalSnapshotRows(configuredScrollback) ?? + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts new file mode 100644 index 00000000000..40ecab2f5a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TAB_HOT_RETAIN_MS, + TERMINAL_WORKTREE_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + canParkTerminalTabRenderer, + canParkTerminalWorktreeRenderers, + getTerminalTabColdParkRecheckDelayMs, + getTerminalWorktreeColdParkRecheckDelayMs, + isSnapshotBackedTerminalPty, + selectColdParkedTerminalTabs, + selectColdParkedTerminalWorktrees +} from './terminal-hidden-view-parking' + +describe('isSnapshotBackedTerminalPty', () => { + it('allows local daemon sessions owned by the worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true) + expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true) + }) + + // Why: separator-less ids ('1', '2', 'pty-local-detached') come from the + // daemon-fail-open LocalPtyProvider and have no daemon session model — + // revealing a parked pane would silently respawn a fresh shell, so they + // must not count as snapshot-backed (changed from the ported prior art). + it('rejects separator-less local PTY ids that lack a daemon session model', () => { + expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false) + }) + + it('rejects tabs that do not have a PTY yet', () => { + expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false) + }) + + it('rejects daemon sessions owned by another worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false) + }) + + it('rejects SSH and remote runtime PTY handles', () => { + expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false) + }) +}) + +describe('canParkTerminalWorktreeRenderers', () => { + const hiddenSinceMs = 1_000 + const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + it('parks hidden local terminal renderers after the idle delay', () => { + expect(canParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('never parks when the settings kill switch disables parking', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + parkingEnabled: false, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10 + }) + ).toBe(false) + }) + + it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false) + expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe( + false + ) + expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1 + }) + ).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + const shortDelayArgs = { ...base, coldParkDelayMs: 100 } + expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) + + it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }, + { id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' } + ] + }) + ).toBe(false) + }) + + it('keeps renderers mounted while a tab has startup or activation work pending', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } } + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true } + ] + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ] + }) + ).toBe(false) + }) +}) + +describe('canParkTerminalTabRenderer', () => { + const hiddenSinceMs = 1_000 + const base = { + worktreeId: 'wt-1', + terminalTab: { + id: 'tab-1', + ptyId: 'wt-1@@session-1', + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + }, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + } + + it('parks an idle hidden local tab and honors the kill switch', () => { + expect(canParkTerminalTabRenderer(base)).toBe(true) + expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 }) + ).toBe(false) + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) +}) + +describe('selectColdParkedTerminalWorktrees', () => { + const nowMs = 500_000 + + function localCandidate(worktreeId: string, hiddenSinceMs: number) { + return { + worktreeId, + terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }], + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps recent hidden local worktrees hot up to the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['wt-3'])) + }) + + it('cold-parks aged local worktrees even when under the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 4 + }) + + expect(selected).toEqual(new Set(['wt-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park terminals without local snapshot recovery', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + { + ...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }] + }, + { + ...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }] + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['wt-local'])) + }) + + it('keeps visible, measuring, portaled, and pending terminals mounted', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + { + ...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + isVisible: true + }, + { + ...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + shouldMeasureHiddenWorktree: true + }, + { + ...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + { + ...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [ + { + id: 'tab-activation', + ptyId: 'wt-activation@@session-1', + pendingActivationSpawn: true + } + ] + }, + localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS) + ], + pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('selectColdParkedTerminalTabs', () => { + const nowMs = 500_000 + + function localTab(id: string, hiddenSinceMs: number) { + return { + id, + ptyId: `wt-1@@session-${id}`, + pendingActivationSpawn: false, + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps visible and recent inactive terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true }, + localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest inactive local tabs beyond the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['tab-3'])) + }) + + it('cold-parks aged inactive local tabs even when under the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 12 + }) + + expect(selected).toEqual(new Set(['tab-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park inactive terminal tabs without local snapshot recovery', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'ssh:ssh-1@@pty-1' + }, + { + ...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'remote:env-1@@terminal-1' + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['tab-local'])) + }) + + it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { + ...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + pendingActivationSpawn: true + } + ], + pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { + it('returns the next cold-park policy deadline', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) + +describe('getTerminalTabColdParkRecheckDelayMs', () => { + it('returns the next terminal-tab cold-park policy deadline', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts new file mode 100644 index 00000000000..1421e8ac53d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -0,0 +1,285 @@ +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' + +// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab +// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible +// working set warm for 5 minutes beyond that. +export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 +export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS +export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 + +// Why: tests override these per call (instead of process.env reads inside the +// module) to shrink the 30s hysteresis to test-friendly durations. +export type TerminalColdParkPolicyOverrides = { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +} + +export type ColdParkableTerminalTab = Pick + +export type TerminalWorktreeColdParkCandidate = { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & { + isVisible: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +// Why: parking relies on the daemon model snapshot to re-hydrate. Remote +// runtime and SSH PTYs have no local snapshot in this phase, and a session id +// minted for another worktree reattaches through a path parking cannot replay. +export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean { + if (!ptyId) { + return false + } + if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + // Why: separator-less ids come from the daemon-fail-open LocalPtyProvider; + // they have no daemon session model, so revealing a parked pane would + // silently respawn a fresh shell instead of restoring the snapshot. + const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId +} + +export function canParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + // Why: callers pass settings.terminalHiddenViewParking !== false — the + // design-doc kill switch that disables parking entirely. + parkingEnabled: boolean + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number +}): boolean { + if ( + !args.parkingEnabled || + args.isVisible || + args.shouldMeasureHiddenWorktree || + args.hasActivityTerminalPortal || + args.hiddenSinceMs === null + ) { + return false + } + if ( + args.nowMs - args.hiddenSinceMs < + (args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS) + ) { + return false + } + return args.terminalTabs.every((tab) => { + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) + }) +} + +export function canParkTerminalTabRenderer(args: { + worktreeId: string + terminalTab: TerminalTabColdParkCandidate + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + coldParkDelayMs?: number +}): boolean { + const tab = args.terminalTab + if ( + !args.parkingEnabled || + tab.isVisible || + tab.hasActivityTerminalPortal || + tab.hiddenSinceMs === null + ) { + return false + } + if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) { + return false + } + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) +} + +type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } + +// Why: hot-retain keeps the most recently hidden ids warm up to the limit; +// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id +// so the selection is deterministic. +function selectIdsBeyondHotRetain( + candidates: ColdParkRetainCandidate[], + args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number } +): Set { + const coldParkedIds = new Set() + const retainedCandidates: ColdParkRetainCandidate[] = [] + for (const candidate of candidates) { + if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) { + coldParkedIds.add(candidate.id) + } else { + retainedCandidates.push(candidate) + } + } + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta + }) + for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) { + coldParkedIds.add(candidate.id) + } + return coldParkedIds +} + +export function selectColdParkedTerminalWorktrees( + args: { + worktrees: readonly TerminalWorktreeColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const worktree of args.worktrees) { + if ( + worktree.hiddenSinceMs === null || + !canParkTerminalWorktreeRenderers({ + ...worktree, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT + }) +} + +export function selectColdParkedTerminalTabs( + args: { + worktreeId: string + terminalTabs: readonly TerminalTabColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const tab of args.terminalTabs) { + if ( + tab.hiddenSinceMs === null || + !canParkTerminalTabRenderer({ + worktreeId: args.worktreeId, + terminalTab: tab, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT + }) +} + +// Why: parking decisions change only at the cold-park and hot-retain +// deadlines, so callers schedule one recheck at the next deadline instead of +// polling. +function nextColdParkDeadlineDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs: number + hotRetainMs: number +}): number | null { + if (!args.parkingEnabled || args.hiddenSinceMs === null) { + return null + } + const pendingDeadlines = [ + args.hiddenSinceMs + args.coldParkDelayMs, + args.hiddenSinceMs + args.hotRetainMs + ].filter((deadlineMs) => deadlineMs > args.nowMs) + return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs +} + +export function getTerminalWorktreeColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + }) +} + +export function getTerminalTabColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts new file mode 100644 index 00000000000..bfd6b988291 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -0,0 +1,564 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type StartedWatcher = { + options: ParkedTerminalByteWatcherOptions + dispose: ReturnType +} + +const startedWatchers: StartedWatcher[] = [] +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + const dispose = vi.fn() + startedWatchers.push({ options, dispose }) + return dispose +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +type ExitSubscription = { + ptyId: string + callback: (code: number) => void + unsubscribe: ReturnType +} + +const exitSubscriptions: ExitSubscription[] = [] +const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => { + const unsubscribe = vi.fn() + exitSubscriptions.push({ ptyId, callback, unsubscribe }) + return unsubscribe +}) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) => + subscribeToPtyExit(ptyId, callback) +})) + +type MockStoreState = { + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + clearRuntimePaneTitle: ReturnType + setTabLayout: ReturnType +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + canWatcherCoverParkedTerminalTab, + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + fallbackParkedPaneCandidates, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +const ptyWrite = vi.fn() +const originalWindow = (globalThis as { window?: unknown }).window + +function capturePanes( + panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], + args?: { tabId?: string; worktreeId?: string } +): void { + captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) +} + +function syncParked(args?: { + worktreeId?: string + tabs?: { id: string; ptyId: string | null }[] + parkedTabIds?: Iterable +}): void { + syncParkedTerminalTabWatchers({ + worktreeId: args?.worktreeId ?? WORKTREE_ID, + tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], + parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]) + }) +} + +describe('terminal-parked-tab-watchers', () => { + beforeEach(() => { + mockStoreState = { + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn() + } + ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + }) + + afterEach(() => { + // Module-level registries persist across tests; clear them through the + // public prune path so each test starts from an empty parked state. + pruneParkedTerminalWatchers(new Set()) + startedWatchers.length = 0 + exitSubscriptions.length = 0 + vi.clearAllMocks() + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[0].options).toMatchObject({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: 1, + drivesTabTitle: true + }) + expect(startedWatchers[1].options).toMatchObject({ + ptyId: SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + startedWatchers[0].options.sendInput('\x1b[?2031;1$y') + expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y') + }) + + it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID }) + }) + + it('never starts watchers for remote-runtime or SSH PTYs', () => { + capturePanes([ + { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] }) + + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + // Why: the tab is still tracked as parked so debug introspection + // (window.__terminalParkingDebug) reflects every parked tab. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('keeps existing watchers across repeated syncs of the same parked state', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes the watcher and exit subscription when the tab unparks', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ parkedTabIds: [] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes the watcher when the tab closes while parked', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ tabs: [], parkedTabIds: [TAB_ID] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes a PTY watcher when that PTY exits while parked', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID) + exited?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // The tab itself is still parked, only the exited PTY's watcher is gone. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('seeds each watcher with the pane slot last known runtime title', () => { + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature') + expect(startedWatchers[1].options.initialTitle).toBeUndefined() + }) + + it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + disposeParkedTerminalWatchersForPtyIds([PTY_ID]) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // Why: the entry survives so a sleeping parked tab cannot restart a + // watcher against its stale PTY ids before wake re-mints them. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + syncParked() + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + }) + + it('restarts watchers from store layout when the tab PTY was re-minted', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } + } + syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID }) + }) + + it('scopes sync disposal to the given worktree', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9` + capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], { + tabId: 'tab-other', + worktreeId: OTHER_WORKTREE_ID + }) + syncParked({ + worktreeId: OTHER_WORKTREE_ID, + tabs: [{ id: 'tab-other', ptyId: otherPtyId }], + parkedTabIds: ['tab-other'] + }) + + // Unparking everything in the other worktree must not touch this one. + syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + disposeParkedTerminalWatchersForWorktree(WORKTREE_ID) + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID])) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + describe('shouldDeferParkedPtyExitTabClose', () => { + const closeTab = vi.fn() + + // Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab. + function hostOnPtyExit(tabId: string, ptyId: string): void { + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } + closeTab(tabId) + } + + it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + // The dead leaf's runtime-title slot cannot pin worktree status. + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) + }) + + it('collapses the exited leaf out of the stored layout when deferring', () => { + // Why (regression, ghost/resurrected pane): a deferred parked exit that + // leaves the leaf and its binding in the stored layout reattaches on + // reveal — the daemon re-creates the exited session id as a fresh shell. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('keeps exit→closeTab parity when the tab is not parked', () => { + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => { + // Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit + // that lands while parked reaches ONLY the watcher sidecar — it must run + // the layout collapse itself or the leaf resurrects on reveal. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0) + + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2) + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() + }) + + it('closes the tab when the last surviving leaf of a parked split exits', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + // First leaf dies: deferred, then its exit sidecar drops the watcher. + hostOnPtyExit(TAB_ID, PTY_ID) + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + expect(closeTab).not.toHaveBeenCalled() + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + }) + + describe('canWatcherCoverParkedTerminalTab', () => { + it('rejects a tab with no unmount capture and no layout snapshot', () => { + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts a current capture whose panes are all snapshot-backed', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects a capture containing a legacy non-UUID leaf id', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('rejects a capture containing a PTY without snapshot backing', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts layout-derived candidates when the capture is stale', () => { + capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects layout-derived candidates missing a leaf PTY binding', () => { + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + }) +}) + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts new file mode 100644 index 00000000000..8871bdf4905 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -0,0 +1,278 @@ +/** + * Parked terminal tab watcher lifecycle. + * + * Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer + * byte parsers. This module owns the pane-less replacement: it remembers the + * unmounted panes' identities (pane id / leaf id), starts one + * parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on + * reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps + * live in terminal-parked-watcher-registry so the terminals store slice can + * dispose watchers without importing this store-coupled module. + * See docs/reference/terminal-hidden-view-parking.md. + */ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { subscribeToPtyExit } from './pty-dispatcher' +import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' +import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +// Why: re-exported so park wiring keeps one import surface; the registry +// split exists only to break the store-slice import cycle. +export { + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers +} from './terminal-parked-watcher-registry' +export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' + +export type ParkableTerminalTabModel = Pick + +type ParkedPaneFallbackState = { + terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] +} + +// Why: if no unmount capture exists (or it predates a PTY respawn), derive +// pane identities from the persisted layout snapshot. Numeric pane ids are +// unknown here: reuse the single existing runtime-title slot when unambiguous +// so a stale "working" title still gets overwritten, otherwise use negative +// slots that can never collide with real PaneManager ids. +export function fallbackParkedPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const layout = state.terminalLayoutsByTabId[tab.id] + const leafIds = collectLeafIdsInOrder(layout?.root) + if (leafIds.length === 0) { + return [] + } + const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {} + const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}) + const reusableSlot = + leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null + return leafIds.map((leafId, index) => ({ + ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), + paneId: reusableSlot ?? -(index + 1), + leafId, + drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0 + })) +} + +// Why: unmount captures and layout fallbacks must resolve identically for the +// watcher start path and the park-eligibility coverage check, or a tab could +// pass the check and then start with different (uncoverable) candidates. +function resolveParkedTerminalPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const captured = capturedPanesByTabId.get(tab.id) + // Why: a capture that no longer mentions the tab's current PTY is stale + // (the PTY was re-minted since the unmount); fall back to the layout. + const capturedIsCurrent = + captured !== undefined && + captured.panes.length > 0 && + (tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) + return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state) +} + +/** + * Whether the parked byte watchers can fully cover this tab's PTYs (some + * candidate exists and every candidate has a snapshot-backed PTY bound to a + * valid leaf). Hosts must refuse to park a tab that fails this check — + * parking it would silently drop bell/title/completion side effects, the + * exact failure that sank the first parking attempt. + */ +export function canWatcherCoverParkedTerminalTab( + worktreeId: string, + tab: ParkableTerminalTabModel +): boolean { + const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()) + return ( + panes.length > 0 && + panes.every( + (pane) => + pane.ptyId !== null && + isTerminalLeafId(pane.leafId) && + isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) + ) + ) +} + +function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void { + const state = useAppStore.getState() + const panes = resolveParkedTerminalPaneCandidates(tab, state) + const disposersByPtyId = new Map void>() + const paneIdByPtyId = new Map() + for (const pane of panes) { + const ptyId = pane.ptyId + // Why: the park policy already excludes non-snapshot-backed PTYs, but the + // tab model can change between the park decision and this effect — guard + // again so remote-runtime/SSH PTYs never get a local watcher. Legacy + // non-UUID leaf ids are skipped because makePaneKey throws on them. + if ( + !ptyId || + disposersByPtyId.has(ptyId) || + !isTerminalLeafId(pane.leafId) || + !isSnapshotBackedTerminalPty(ptyId, worktreeId) + ) { + continue + } + const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] + const disposeWatcher = startParkedTerminalByteWatcher({ + ptyId, + tabId: tab.id, + worktreeId, + leafId: pane.leafId, + paneId: pane.paneId, + drivesTabTitle: pane.drivesTabTitle, + // Why: seed the watcher's agent tracker with the pane's last known + // title so an agent already working at park time still notifies when + // it finishes while parked. + ...(initialTitle !== undefined ? { initialTitle } : {}), + // Why: no pane transport exists while parked; write straight to the + // PTY, the same channel background agent launches use. + sendInput: (data) => window.api.pty.write(ptyId, data) + }) + // Why: a PTY that exits while parked has no pane to run exit cleanup; at + // minimum its watcher must not outlive it. + const unsubscribeExit = subscribeToPtyExit(ptyId, () => { + // Why: while parked this sidecar is the ONLY exit observer — the hosts' + // onPtyExit runs from a mounted TerminalPane. Run the observed-exit + // teardown's data half here for a multi-leaf tab, or the dead leaf's + // stale binding reattaches on reveal and the daemon re-creates the + // exited session id as a fresh shell, resurrecting the pane. + if (disposersByPtyId.size > 1) { + useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) + collapseParkedExitedLeaf(tab.id, ptyId) + } + disposersByPtyId.get(ptyId)?.() + disposersByPtyId.delete(ptyId) + // Why: with the last watcher gone there is nothing left to watch or + // dispose; dropping the entry keeps the registry bounded to parked + // tabs that still hold live PTYs. + const entry = parkedWatchersByTabId.get(tab.id) + if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) { + parkedWatchersByTabId.delete(tab.id) + } + }) + paneIdByPtyId.set(ptyId, pane.paneId) + disposersByPtyId.set(ptyId, () => { + unsubscribeExit() + disposeWatcher() + }) + } + // Why: tracked even with zero watchers so parked-state introspection + // (window.__terminalParkingDebug) reflects every parked tab. + parkedWatchersByTabId.set(tab.id, { + worktreeId, + tabPtyId: tab.ptyId, + paneIdByPtyId, + disposersByPtyId + }) +} + +/** + * Hosts call this from their onPtyExit handlers before closing the tab. + * Returns true when the close must be deferred: a parked tab has no + * PaneManager to promote split siblings, so the live exit path degenerates to + * "close the whole tab" — which would kill the surviving sibling panes. The + * reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs + * return false so exit→closeTab parity is preserved. Also clears the dead + * leaf's runtime-title slot so a stale title cannot pin worktree status. + */ +export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return false + } + const paneId = entry.paneIdByPtyId.get(ptyId) + if (paneId !== undefined) { + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + const remaining = entry.disposersByPtyId.size + if (remaining === 0) { + return false + } + // Why: this runs from the PTY exit handler, before the exit sidecar above + // removes the dead PTY's watcher — so the watcher count still includes the + // exiting PTY. More than one watcher (or an exit for an unwatched PTY) + // means live sibling leaves remain. + const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId) + if (defer) { + collapseParkedExitedLeaf(tabId, ptyId) + } + return defer +} + +// Why: deferring the tab close is not enough — a stale leaf binding left in +// the stored layout reattaches on reveal, and the daemon re-creates the exited +// session id as a fresh shell, resurrecting a pane whose shell already ended. +// With no PaneManager mounted, the observed-exit teardown's data half runs +// here instead: collapse the leaf out of the stored layout so the reveal +// replays only the surviving panes. +function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { + const state = useAppStore.getState() + const layout = state.terminalLayoutsByTabId[tabId] + const leafId = + capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? + Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] + if (!leafId) { + return + } + const detached = detachTerminalLayoutLeaf(layout, leafId) + if (detached) { + state.setTabLayout(tabId, detached.sourceLayout) + } +} + +/** + * Reconciles watchers for one worktree against its rendered parked set. + * Callers run this from an effect keyed on the committed render state, so + * disposal lands in the same effect flush as a reveal remount (before any + * PTY data IPC can be delivered) and start lands after the park unmount. + */ +export function syncParkedTerminalTabWatchers(args: { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet +}): void { + const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) { + disposeParkedTabWatchers(tabId) + } + } + // Why: captures for closed tabs have no future park/reveal; drop them so + // the registry stays bounded by live tabs. + for (const [tabId, capture] of capturedPanesByTabId) { + if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const entry = parkedWatchersByTabId.get(tab.id) + if (entry && entry.tabPtyId !== tab.ptyId) { + disposeParkedTabWatchers(tab.id) + } + if (!parkedWatchersByTabId.has(tab.id)) { + startParkedTabWatchers(args.worktreeId, tab) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts new file mode 100644 index 00000000000..d6dd76e817a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -0,0 +1,107 @@ +/** + * Parked terminal watcher registry (store-free bookkeeping). + * + * Why a separate module: shutdownWorktreeTerminals (a store slice) must + * synchronously dispose parked watchers, but the watcher lifecycle module + * imports the store — a slice importing it would re-enter store creation + * mid-evaluation. Keeping the maps and pure disposal here lets the slice + * import cycle-free, mirroring how pty-dispatcher exports its handler maps. + */ + +export type ParkedTerminalPaneCapture = { + ptyId: string | null + /** PaneManager numeric pane id the live pane used for runtime titles. */ + paneId: number + /** Stable terminal-layout leaf UUID (paneKey attribution). */ + leafId: string + drivesTabTitle: boolean +} + +export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } + +export const capturedPanesByTabId = new Map() + +// Why: PaneManager pane ids die with the unmounted pane, but the watcher must +// keep writing the exact runtime-title slots the live pane used — a different +// slot would strand a stale "working" title that pins worktree status. +// TerminalPane unmount records the identities here for the park wiring. +export function captureParkedTerminalPaneCandidates( + tabId: string, + worktreeId: string, + panes: ParkedTerminalPaneCapture[] +): void { + capturedPanesByTabId.set(tabId, { worktreeId, panes }) +} + +export type ParkedTabWatcherEntry = { + worktreeId: string + /** Tab-level ptyId at watcher start; a change means the PTY was re-minted + * (e.g. wake respawn) and the watchers must restart against fresh ids. */ + tabPtyId: string | null + /** Runtime-title slot each watcher writes, so parked PTY-exit handling can + * clear the dead leaf's slot (no live pane will ever overwrite it). */ + paneIdByPtyId: Map + disposersByPtyId: Map void> +} + +export const parkedWatchersByTabId = new Map() + +export function getParkedTerminalWatcherTabIds(): string[] { + return Array.from(parkedWatchersByTabId.keys()) +} + +export function disposeParkedTabWatchers(tabId: string): void { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return + } + parkedWatchersByTabId.delete(tabId) + for (const dispose of entry.disposersByPtyId.values()) { + dispose() + } + entry.disposersByPtyId.clear() +} + +/** + * Synchronously disposes any parked watcher subscribed to these PTYs. + * shutdownWorktreeTerminals silences the live transports' final teardown + * flush via unregisterPtyDataHandlers, but parked watchers ride the + * dispatcher SIDECAR channel that call does not touch — without this, the + * flush still marks unread and arms notification timers for a worktree that + * is already sleeping or deleted. The tab entries are kept so a sleeping + * parked tab does not restart watchers against its stale PTY ids; wake + * re-mints the ids and the sync path restarts watchers then. + */ +export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void { + for (const entry of parkedWatchersByTabId.values()) { + for (const ptyId of ptyIds) { + const dispose = entry.disposersByPtyId.get(ptyId) + if (dispose) { + entry.disposersByPtyId.delete(ptyId) + dispose() + } + } + } +} + +export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId === worktreeId) { + disposeParkedTabWatchers(tabId) + } + } +} + +/** Drops watchers and captures for worktrees that no longer exist. */ +export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (!liveWorktreeIds.has(entry.worktreeId)) { + disposeParkedTabWatchers(tabId) + } + } + for (const [tabId, capture] of capturedPanesByTabId) { + if (!liveWorktreeIds.has(capture.worktreeId)) { + capturedPanesByTabId.delete(tabId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts new file mode 100644 index 00000000000..40e226cf5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null } + +let mockE2EConfig: MockE2EConfig + +vi.mock('@/lib/e2e-config', () => ({ + get e2eConfig() { + return mockE2EConfig + } +})) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + getParkedTerminalWatcherTabIds: () => ['tab-parked'] +})) + +const originalWindow = (globalThis as { window?: unknown }).window + +type TerminalParkingE2EOverridesModule = { + getTerminalParkingPolicyOverrides: () => { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number + } + registerTerminalParkingDebugHandle: () => void +} + +async function importOverridesModule(): Promise { + vi.resetModules() + return import('./terminal-parking-e2e-overrides') +} + +describe('getTerminalParkingPolicyOverrides', () => { + beforeEach(() => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('ignores the delay override outside e2e (exposeStore off)', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ + coldParkDelayMs: 500, + hotRetainMs: 500 + }) + }) + + it('returns no overrides when no delay is configured', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('registers window.__terminalParkingDebug on import under exposeStore', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const testWindow: { + __terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] } + } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500) + expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked']) + }) + + it('does not register the debug handle outside e2e', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + const testWindow: { __terminalParkingDebug?: unknown } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts new file mode 100644 index 00000000000..1717b170bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -0,0 +1,34 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + type TerminalColdParkPolicyOverrides +} from './terminal-hidden-view-parking' +import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' + +// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park +// hysteresis and the hot-retain window — recently hidden tabs otherwise sit +// in the hot-retain working set for 5 minutes and never park within a test +// run. Gated on exposeStore so packaged builds ignore stray env vars. +export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { + const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 + ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs } + : {} +} + +export function registerTerminalParkingDebugHandle(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalParkingDebug = { + parkDelayMs: + getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + parkedTabIds: () => getParkedTerminalWatcherTabIds() + } +} + +// Why: the parking e2e spec gates on window.__terminalParkingDebug existing +// shortly after launch. This module is statically imported by the park +// wiring, so registering at module load makes the handle visible before any +// tab parks. +registerTerminalParkingDebugHandle() diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts new file mode 100644 index 00000000000..9190a9bdfa6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { + _resetParserHandlerReportsForTests, + guardParserHandler +} from './terminal-parser-handler-guard' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetParserHandlerReportsForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('guardParserHandler', () => { + it('passes arguments and return value through for healthy handlers', () => { + const handler = vi.fn((data: string) => data === 'handled') + const guarded = guardParserHandler('test-handler', handler) + expect(guarded('handled')).toBe(true) + expect(guarded('other')).toBe(false) + expect(handler).toHaveBeenCalledTimes(2) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('degrades a throwing handler to "not handled" and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('exploding-handler', () => { + throw new TypeError('synthetic handler failure') + }) + expect(guarded()).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ + handler: 'exploding-handler', + errorName: 'TypeError', + errorMessage: 'synthetic handler failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per handler', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('spammy-handler', () => { + throw new Error('always fails') + }) + for (let i = 0; i < 20; i++) { + guarded() + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps the real xterm write pipeline alive through a throwing handler (inverse of the wedge repro)', () => { + // Why: xterm-write-buffer-stall.repro.test.ts proves an UNguarded throwing + // handler permanently wedges the WriteBuffer. This is the fix's proof: + // the same poison sequence through a GUARDED handler keeps completing + // writes. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler( + { final: 'z' }, + guardParserHandler('poisoned-csi', () => { + throw new Error('synthetic parser handler failure') + }) + ) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).not.toThrow() + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual(['poisoned', 'after', 'later']) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ handler: 'poisoned-csi' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts new file mode 100644 index 00000000000..cb6b5bc51bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts @@ -0,0 +1,44 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's EscapeSequenceParser invokes custom CSI/OSC handlers +// synchronously inside WriteBuffer._innerWrite, which has no try/catch. A +// handler throw skips the loop's tail re-schedule, and write() only re-arms +// on an EMPTY buffer — one throw permanently freezes the pane (output stops; +// a pending replay guard never releases and silently eats keystrokes). +// Verified against vendored xterm 6.1.0-beta.287 in +// xterm-write-buffer-stall.repro.test.ts. Same escape class as +// terminal-link-provider-guard.ts, applied to parser handlers. +const MAX_REPORTS_PER_HANDLER = 5 +const reportCountsByHandler = new Map() + +/** + * Wrap a custom parser handler so a synchronous throw is reported and + * degraded to "not handled" (xterm falls through to the previous/default + * handler) instead of wedging the terminal's write pipeline. + */ +export function guardParserHandler( + handlerName: string, + handler: (...args: HandlerArgs) => boolean +): (...args: HandlerArgs) => boolean { + return (...args: HandlerArgs): boolean => { + try { + return handler(...args) + } catch (error: unknown) { + const reported = reportCountsByHandler.get(handlerName) ?? 0 + if (reported < MAX_REPORTS_PER_HANDLER) { + reportCountsByHandler.set(handlerName, reported + 1) + console.error(`[terminal] parser handler "${handlerName}" threw`, error) + recordRendererCrashBreadcrumb('terminal_parser_handler_error', { + handler: handlerName, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } + return false + } + } +} + +export function _resetParserHandlerReportsForTests(): void { + reportCountsByHandler.clear() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts new file mode 100644 index 00000000000..3280ca36dd1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal-pty-ack-gate cumulative totals', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + // Why: the cumulative totals are module state; a fresh module per test + // mirrors a fresh renderer page (renderer lifecycle reset). + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('sends monotonic cumulative totals alongside per-chunk deltas', async () => { + const { ackPtyData, getProcessedPtyCharTotals } = await loadAckGate() + + ackPtyData('pty-a', 5) + ackPtyData('pty-a', 7) + ackPtyData('pty-b', 3) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-a', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-a', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-b', 3, 3) + expect(getProcessedPtyCharTotals()).toEqual({ 'pty-a': 12, 'pty-b': 3 }) + }) + + it('clears a PTY total so a reused id restarts from zero on both sides', async () => { + const { ackPtyData, clearProcessedPtyCharTotal, getProcessedPtyCharTotals } = + await loadAckGate() + + ackPtyData('pty-a', 9) + clearProcessedPtyCharTotal('pty-a') + + expect(getProcessedPtyCharTotals()).toEqual({}) + + ackPtyData('pty-a', 4) + expect(ackDataMock).toHaveBeenLastCalledWith('pty-a', 4, 4) + }) +}) + +describe('terminal-pty-ack-gate parse-deferred crediting', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('settles an unclaimed delivery credit at return', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + deliverPtyDataWithDeferredAck('pty-a', 42, () => {}) + + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 42, 42) + }) + + it('defers a claimed credit to the scheduler callback and fires once', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let credit: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 10, () => { + credit = takeCurrentPtyDeliveryAckCredit() + }) + + // Claimed: nothing credited at delivery return. + expect(ackDataMock).not.toHaveBeenCalled() + credit!() + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 10, 10) + // Fire-once: split slices / discard paths may re-invoke harmlessly. + credit!() + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) + + it('hands out the credit only once per delivery', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let first: (() => void) | null = null + let second: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 5, () => { + first = takeCurrentPtyDeliveryAckCredit() + second = takeCurrentPtyDeliveryAckCredit() + }) + + expect(first).not.toBeNull() + expect(second).toBeNull() + }) + + it('returns null outside a delivery', async () => { + const { takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + expect(takeCurrentPtyDeliveryAckCredit()).toBeNull() + }) + + it('settles the credit when the handler throws so the PTY never wedges', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + expect(() => + deliverPtyDataWithDeferredAck('pty-a', 7, () => { + throw new Error('bad sidecar') + }) + ).toThrow('bad sidecar') + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 7, 7) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index bea658434be..31733aebc47 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -18,13 +18,26 @@ type E2eTerminalPtyAckGateWindow = Window & { const e2eTerminalAckGatePtyIds = new Set() const e2eTerminalAckGateHeldChars = new Map() +// Why: monotonic per-PTY totals of processed chars, mirrored to main as +// TCP-style cumulative ACKs so a lost ACK message never becomes permanent +// in-flight debt. Cleared on pty:exit so a reused id restarts aligned with +// main's fresh accounting; a renderer reload resets it with the page. +const processedPtyCharTotals = new Map() + +function sendPtyAck(ptyId: string, chars: number): void { + const processedChars = (processedPtyCharTotals.get(ptyId) ?? 0) + chars + processedPtyCharTotals.set(ptyId, processedChars) + // Why: keep the legacy per-chunk delta alongside the cumulative total so an + // older main (dev hot-reload mix) still credits deltas. + window.api.pty.ackData?.(ptyId, chars, processedChars) +} function releaseE2eTerminalAckGate(): void { const held = Array.from(e2eTerminalAckGateHeldChars.entries()) e2eTerminalAckGatePtyIds.clear() e2eTerminalAckGateHeldChars.clear() for (const [ptyId, chars] of held) { - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) } } @@ -58,9 +71,81 @@ export function exposeE2eTerminalPtyAckGate(): void { } export function ackPtyData(ptyId: string, chars: number): void { + // Why: held e2e-gate chars stay out of the cumulative total too, so a + // delivery-resync probe cannot leak them past the simulated backpressure. if (e2eTerminalAckGatePtyIds.has(ptyId)) { e2eTerminalAckGateHeldChars.set(ptyId, (e2eTerminalAckGateHeldChars.get(ptyId) ?? 0) + chars) return } - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) +} + +// ─── Parse-deferred ACK crediting ─────────────────────────────────── +// Why: ACKing at dispatcher enqueue made main's 512KB in-flight window mean +// "bytes RECEIVED", not "bytes PARSED" — under flood the renderer's write +// queue grew unbounded behind instant ACKs, main saw no backpressure, crossed +// its pending cap, and dropped output (rc.7.perf DSR timeouts). Crediting is +// now deferred to the output scheduler's consume point, so in-flight becomes +// true parse backpressure and main's producer flow control pauses the shell +// instead of dropping. + +type DeferredPtyAckCredit = { + ptyId: string + chars: number + claimed: boolean + credited: boolean +} + +let currentDeliveryCredit: DeferredPtyAckCredit | null = null + +function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void { + // Why fire-once: split queue chunks and discard paths may both touch the + // same delivery; the invariant is exactly one credit per delivered chunk. + if (credit.credited) { + return + } + credit.credited = true + ackPtyData(credit.ptyId, credit.chars) +} + +/** Runs one pty:data delivery with a parse-deferred ACK credit. If the + * handler hands bytes to the output scheduler, the claimed credit fires when + * the scheduler consumes (writes or discards) them; any credit left + * unclaimed fires here at return, so a chunk the handler drops outright can + * never leave main's in-flight window permanently open. */ +export function deliverPtyDataWithDeferredAck( + ptyId: string, + chars: number, + deliver: () => void +): void { + const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false } + currentDeliveryCredit = credit + try { + deliver() + } finally { + currentDeliveryCredit = null + if (!credit.claimed) { + creditDeferredPtyAck(credit) + } + } +} + +/** Claims the in-progress delivery's credit for the output scheduler. Returns + * a fire-once callback, or null when outside a delivery or already claimed + * (only the FIRST scheduler write of a delivery carries the credit). */ +export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null { + const credit = currentDeliveryCredit + if (!credit || credit.claimed) { + return null + } + credit.claimed = true + return () => creditDeferredPtyAck(credit) +} + +export function getProcessedPtyCharTotals(): Record { + return Object.fromEntries(processedPtyCharTotals) +} + +export function clearProcessedPtyCharTotal(ptyId: string): void { + processedPtyCharTotals.delete(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 07770304a26..7acbd6a5ff9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -6,6 +6,25 @@ import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const +// Why: capture now appends an absolute cursor restore (see +// terminal-serialize-absolute-cursor.ts), so pane mocks must expose the +// cursor fields and content expectations carry the home-position CUP suffix. +const CURSOR_HOME = '\x1b[1;1H' + +function mockTerminal(scrollback: number): { + options: { scrollback: number } + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} { + return { + options: { scrollback }, + cols: 80, + rows: 24, + buffer: { active: { cursorX: 0, cursorY: 0 } } + } +} + const mocks = vi.hoisted(() => ({ flushTerminalOutput: vi.fn() })) @@ -76,7 +95,7 @@ describe('captureTerminalShutdownLayout', () => { const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') const order: string[] = [] const terminal = { - options: { scrollback: 1_000 }, + ...mockTerminal(1_000), pendingOutput: '' } const pane = { @@ -115,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, expandedLeafId: null, - buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' }, + buffersByLeafId: { [LEAF_ID]: `snapshot:queued-before-quit${CURSOR_HOME}` }, ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }, titlesByLeafId: { [LEAF_ID]: 'build logs' } }) @@ -127,7 +146,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 50_000 } }, + terminal: mockTerminal(50_000), serializeAddon: { serialize: vi.fn(() => 'x'.repeat(512 * 1024)) } @@ -166,7 +185,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 512 } }, + terminal: mockTerminal(512), serializeAddon: { serialize: vi.fn((options?: { scrollback?: number }) => multibyteRow.repeat(options?.scrollback ?? 0) @@ -192,7 +211,12 @@ describe('captureTerminalShutdownLayout', () => { expect(getUtf8ByteLength(buffer)).toBeLessThanOrEqual( TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT ) - expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT / 2) + // Each 'e-acute' row is 2048 UTF-8 bytes; the CUP suffix joins the byte + // accounting, so one fewer row fits than the bare limit would allow. + const fittingRows = Math.floor( + (TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT - CURSOR_HOME.length) / 2048 + ) + expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length) }) it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => { @@ -201,7 +225,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -239,7 +263,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'dead scrollback') } @@ -248,7 +272,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'live scrollback') } @@ -275,8 +299,8 @@ describe('captureTerminalShutdownLayout', () => { expect(layout.activeLeafId).toBe(LEAF_ID_2) expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-live' }) expect(layout.buffersByLeafId).toEqual({ - [LEAF_ID]: 'dead scrollback', - [LEAF_ID_2]: 'live scrollback' + [LEAF_ID]: `dead scrollback${CURSOR_HOME}`, + [LEAF_ID_2]: `live scrollback${CURSOR_HOME}` }) }) @@ -286,7 +310,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'first scrollback') } @@ -295,7 +319,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'second scrollback') } @@ -335,7 +359,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -344,7 +368,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -373,7 +397,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -382,7 +406,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index c0ee1de1713..4e6d1698787 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -6,6 +6,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT @@ -66,7 +67,12 @@ export function captureTerminalShutdownLayout({ flushTerminalOutput(pane.terminal) const leafId = pane.leafId let scrollback = pane.terminal.options.scrollback ?? 10_000 - let serialized = pane.serializeAddon.serialize({ scrollback }) + // Why serializeWithAbsoluteCursor: these buffers replay into fresh + // xterms on session restore, and SerializeAddon's relative cursor + // restore lands one column short after a wrap-pending final row. + let serialized = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback + }) // Why: SSH sleep keeps this string in session JSON; cap by UTF-8 // bytes so non-ASCII scrollback cannot bypass the intended bound. if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) { @@ -75,7 +81,9 @@ export function captureTerminalShutdownLayout({ let best = '' while (lo <= hi) { const mid = Math.floor((lo + hi) / 2) - const attempt = pane.serializeAddon.serialize({ scrollback: mid }) + const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: mid + }) if (fitsSessionScrollbackByteLimit(attempt)) { best = attempt lo = mid + 1 diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts new file mode 100644 index 00000000000..84a91c2e7c4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest, + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer, + type TerminalSideEffectFactConsumerCallbacks +} from './terminal-side-effect-facts-handler' + +const PTY_ID = 'wt-1#1' + +function createCallbackRecorder(): { + callbacks: TerminalSideEffectFactConsumerCallbacks + events: unknown[][] +} { + const events: unknown[][] = [] + return { + events, + callbacks: { + onTitleChange: (normalizedTitle, rawTitle) => + events.push(['title', normalizedTitle, rawTitle]), + onBell: () => events.push(['bell']), + onAgentBecameIdle: (title) => events.push(['idle', title]), + onAgentBecameWorking: () => events.push(['working']), + onAgentExited: () => events.push(['exited']) + } + } +} + +function batch( + facts: TerminalSideEffectBatch['facts'], + options: Partial = {} +): TerminalSideEffectBatch { + return { ptyId: PTY_ID, seq: 0, facts, ...options } +} + +describe('isMainTerminalSideEffectAuthorityForPty', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function setPersistedSettingsSync(settings: unknown): void { + ;(globalThis as { window: unknown }).window = { + api: { settings: { getSync: () => settings } } + } + } + + it('is on by default for PTYs whose bytes transit local main', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null }) + ).toBe(true) + // Why: settings hydrate asynchronously; the default-on switch must not + // flip authority off during the null-settings startup window. + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('is off for remote-runtime PTYs regardless of the setting', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: 'env-1' + }) + ).toBe(false) + }) + + it('is off when the kill switch is disabled', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: false }, + runtimeEnvironmentId: null + }) + ).toBe(false) + }) + + it('honors the persisted kill switch before settings hydrate', () => { + // Why: the authority decision is made once at transport creation; a pane + // bound during startup must not pick main authority when the user + // persisted the switch off. + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(false) + }) + + it('stays on pre-hydration when the persisted switch is on or unset', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: true }) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + + _resetTerminalSideEffectFactConsumersForTest() + setPersistedSettingsSync({}) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('prefers hydrated settings over the persisted sync read', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: null + }) + ).toBe(true) + }) + + it('caches the sync read so panes do not re-block per bind', () => { + const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + ;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } } + + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + + expect(getSync).toHaveBeenCalledTimes(1) + }) +}) + +describe('registerTerminalSideEffectFactConsumer', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('routes live facts to the registered consumer in batch order', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'agent-idle', title: '✳ Claude' }, + { kind: 'bell' } + ]) + ) + + expect(events).toEqual([ + ['title', '⠋ Claude', '⠋ Claude'], + ['working'], + ['title', '✳ Claude', '✳ Claude'], + ['idle', '✳ Claude'], + ['bell'] + ]) + }) + + it('routes command-finished and pr-link facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-finished', exitCode: 130 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { kind: 'command-finished', exitCode: null } + ]) + ) + + expect(events).toEqual([ + ['finished', 130], + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['finished', null] + ]) + }) + + it('routes command-code scrape facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ]) + ) + + expect(events).toEqual([ + ['cc-working', 'Fix the spinner'], + ['cc-done', 'Fix the spinner'] + ]) + }) + + it('never replays command-code scrape facts', () => { + // Why: a replayed working/done seed would resurrect a finished turn's + // status row — replay batches restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('routes 2031-subscribe facts to the registered consumer but never replays them', () => { + // Why: the fact lets hidden-delivery-gated views answer the color-scheme + // query without byte access; a replayed subscribe would re-answer a query + // the snapshot already satisfied. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onMode2031Subscribe: () => events.push(['2031-subscribe']) + } + }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }])) + expect(events).toEqual([['2031-subscribe']]) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: '2031-subscribe' } + ], + { replay: true, seq: 5 } + ) + ) + expect(events).toEqual([['2031-subscribe'], ['title', 'restored']]) + }) + + it('never replays command-finished or pr-link facts', () => { + // Why: like bells and agent transitions, command/PR facts are attention + // signals — replay snapshots restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('passes stale-clear provenance through to the title and idle callbacks', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle, _rawTitle, meta) => + events.push(['title', normalizedTitle, meta]), + onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'agent-idle', title: 'Codex done' }, + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + ) + + expect(events).toEqual([ + ['idle', 'Codex done', undefined], + ['title', 'Codex', { staleWorkingTitleClear: true }], + ['idle', 'Codex', { staleWorkingTitleClear: true }] + ]) + }) + + it('drops batches for PTYs without a registered consumer', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' })) + + expect(events).toEqual([]) + }) + + it('applies only title facts from replay batches — no attention replay', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'bell' }, + { kind: 'agent-idle', title: '✳ Claude' } + ], + { replay: true, seq: 10 } + ) + ) + + expect(events).toEqual([['title', '✳ Claude', '✳ Claude']]) + }) + + it('drops a replay title not newer than the last applied live title', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], { + replay: true, + seq: 20 + }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], { + replay: true, + seq: 21 + }) + ) + + expect(events).toEqual([ + ['title', 'live', 'live'], + ['title', 'newer', 'newer'] + ]) + }) + + it('keeps exactly one consumer per PTY: a new registration replaces the old', () => { + const first = createCallbackRecorder() + const second = createCallbackRecorder() + const disposeFirst = registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks + }) + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + // A stale registration's dispose must not evict the live consumer. + disposeFirst() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(first.events).toEqual([]) + expect(second.events).toEqual([['bell'], ['bell']]) + }) + + it('stops routing after the consumer unregisters', () => { + const { callbacks, events } = createCallbackRecorder() + const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + dispose() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(events).toEqual([]) + }) + + it('subscribes to the channel once and routes IPC batches', () => { + let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null + const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => { + channelCallback = callback + return () => { + channelCallback = null + } + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { onSideEffect } } + } + const first = createCallbackRecorder() + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks }) + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks }) + + expect(onSideEffect).toHaveBeenCalledTimes(1) + channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' })) + expect(second.events).toEqual([['bell']]) + }) + + it('applies the title snapshot on register unless the registration was replaced', async () => { + let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {} + const getSideEffectSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot } } + } + + const first = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks, + restoreTitleOnRegister: true + }) + expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID) + + // Replace before the snapshot resolves: the slow snapshot must not fire + // into the superseded registration. + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + resolveSnapshot( + batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ) + await Promise.resolve() + + expect(first.events).toEqual([]) + expect(second.events).toEqual([]) + }) + + it('restores the snapshot title for a live registration', async () => { + const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } + } + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks, + restoreTitleOnRegister: true + }) + + await Promise.resolve() + await Promise.resolve() + + expect(events).toEqual([['title', 'restored', 'restored']]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts new file mode 100644 index 00000000000..6fffdbea1a4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -0,0 +1,242 @@ +/** + * Renderer consumer registry for the `pty:sideEffect` channel. + * + * Why: with main as the side-effect parser for local-daemon/SSH PTYs + * (docs/reference/terminal-side-effect-authority.md), the renderer no longer + * derives title/bell/agent facts from bytes for those PTYs. This module is + * the single channel subscriber; mounted panes and parked-tab watchers + * register exactly one fact consumer per PTY (their existing policy + * callbacks), so every fact has exactly one policy consumer regardless of + * whether the tab is mounted, hidden, or parked. Facts for PTYs without a + * registered consumer are dropped — mirroring today's eager-buffer behavior + * where pre-mount output produces no attention side effects. + */ +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../../../shared/terminal-side-effect-facts' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedAuthorityFlagCache: boolean | null | undefined + +function readPersistedSideEffectAuthorityFlagSync(): boolean | null { + if (persistedAuthorityFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedAuthorityFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null + } catch { + persistedAuthorityFlagCache = null + } + } + return persistedAuthorityFlagCache +} + +/** + * Structural authority predicate: main owns side effects for a PTY when its + * bytes transit local main (everything except remote-runtime PTYs) and the + * kill switch is on. Decided at transport/watcher creation — never per chunk — + * so each fact has one consumer with no race. + */ +export function isMainTerminalSideEffectAuthorityForPty(args: { + settings: Pick | null + /** Remote-runtime owner environment; null means bytes transit local main. */ + runtimeEnvironmentId: string | null +}): boolean { + if (args.runtimeEnvironmentId !== null) { + return false + } + if (args.settings !== null) { + return args.settings.terminalMainSideEffectAuthority !== false + } + // Why: settings hydrate asynchronously, and the authority decision made + // here at transport/watcher creation is never revisited. A pane bound + // before hydration must honor the persisted kill switch — otherwise a user + // who turned main authority off gets startup panes with no byte parsers + // and a fact consumer they disabled. Surfaces without the sync read (web + // remote clients, tests) keep the default-on behavior. + return readPersistedSideEffectAuthorityFlagSync() !== false +} + +export type TerminalSideEffectFactConsumerCallbacks = { + /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s + * stale-title timer — policy must clear title/cache state without + * scheduling task-complete notifications or unread attention. */ + onTitleChange?: ( + normalizedTitle: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ) => void + onBell?: () => void + onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove + * (stale agent-status row drop + interrupt-inference coordination). */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + onPrLink?: (link: TerminalGitHubPRLink) => void + /** Command Code output scrape (no hooks): working seeds the status row; + * done is settle-checked by the pane policy before completing the turn. */ + onCommandCodeWorking?: (prompt: string) => void + onCommandCodeDone?: (prompt: string) => void + /** DECSET 2031 subscribe observed by main's tracker. Registered only by + * hidden-delivery-gated consumers (their bytes never arrive); the theme + * reply is sent renderer-side — query authority stays with the view. */ + onMode2031Subscribe?: () => void +} + +type ConsumerEntry = { + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Output sequence of the last live title fact applied. Replay snapshots at + * or before this point are stale and must not regress the title state. */ + lastLiveTitleSeq: number | null +} + +const consumersByPtyId = new Map() +let channelUnsubscribe: (() => void) | null = null + +function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { + switch (fact.kind) { + case 'title': + entry.lastLiveTitleSeq = seq + entry.callbacks.onTitleChange?.( + fact.normalizedTitle, + fact.rawTitle, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'bell': + entry.callbacks.onBell?.() + return + case 'agent-working': + entry.callbacks.onAgentBecameWorking?.() + return + case 'agent-idle': + entry.callbacks.onAgentBecameIdle?.( + fact.title, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'agent-exited': + entry.callbacks.onAgentExited?.() + return + case 'command-finished': + entry.callbacks.onCommandFinished?.(fact.exitCode) + return + case 'pr-link': + entry.callbacks.onPrLink?.(fact.link) + return + case 'command-code-working': + entry.callbacks.onCommandCodeWorking?.(fact.prompt) + return + case 'command-code-done': + entry.callbacks.onCommandCodeDone?.(fact.prompt) + return + case '2031-subscribe': + entry.callbacks.onMode2031Subscribe?.() + } +} + +function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { + if (batch.replay) { + // Why: the no-attention-replay rule — (re)attach snapshots restore title + // state only; historical bells/completions must never fire again. A replay + // older (by output sequence) than the last live title fact is stale. + if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'title') { + entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) + } + } + return + } + for (const fact of batch.facts) { + applyLiveFact(entry, fact, batch.seq) + } +} + +function handleSideEffectBatch(batch: TerminalSideEffectBatch): void { + const entry = consumersByPtyId.get(batch.ptyId) + if (!entry) { + return + } + applyBatchToConsumer(entry, batch) +} + +function ensureSideEffectChannelSubscription(): void { + if (channelUnsubscribe !== null) { + return + } + // Why: optional-chained from globalThis so unit tests (and any non-preload + // surface) without window.api degrade to "no channel" instead of throwing. + const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect + if (typeof onSideEffect !== 'function') { + return + } + channelUnsubscribe = onSideEffect(handleSideEffectBatch) +} + +export type TerminalSideEffectFactConsumerOptions = { + ptyId: string + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Pull main's title-only replay snapshot on registration. Pane transports + * use this in place of deriving titles from eager-buffer byte replay; + * parked watchers skip it because the pane's runtime title slot is already + * current at park time. */ + restoreTitleOnRegister?: boolean +} + +/** + * Register the single fact consumer for a PTY. A new registration replaces a + * stale one for the same PTY (same semantics as the parked watcher registry): + * two consumers would double-fire bell/completion policy for the same bytes. + */ +export function registerTerminalSideEffectFactConsumer( + options: TerminalSideEffectFactConsumerOptions +): () => void { + ensureSideEffectChannelSubscription() + const entry: ConsumerEntry = { + callbacks: options.callbacks, + lastLiveTitleSeq: null + } + consumersByPtyId.set(options.ptyId, entry) + + if (options.restoreTitleOnRegister) { + const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot + if (typeof getSnapshot === 'function') { + void getSnapshot(options.ptyId) + .then((batch) => { + // Why: apply only while this registration is still the live + // consumer; a slow snapshot must not fire into a replaced one. + if (batch && consumersByPtyId.get(options.ptyId) === entry) { + applyBatchToConsumer(entry, { ...batch, replay: true }) + } + }) + .catch(() => {}) + } + } + + return () => { + if (consumersByPtyId.get(options.ptyId) === entry) { + consumersByPtyId.delete(options.ptyId) + } + } +} + +/** Test seam: deliver a batch as if it arrived on the channel. */ +export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void { + handleSideEffectBatch(batch) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalSideEffectFactConsumersForTest(): void { + consumersByPtyId.clear() + channelUnsubscribe?.() + channelUnsubscribe = null + persistedAuthorityFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts new file mode 100644 index 00000000000..ae5fa99b63f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -0,0 +1,378 @@ +// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY +// title tracker in main alongside the renderer transport's byte parser. Both +// must derive IDENTICAL ordered title/status facts from the same bytes, or +// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift +// from what the renderer shows. This harness feeds identical byte fixtures +// through the renderer `createPtyOutputProcessor` and through main's +// consumption shape (OSC 9999 strip → shared title tracker) and asserts the +// event sequences match. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' +import { createPtyOutputProcessor } from './pty-transport' +import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type TitleFactEvent = + | { kind: 'title'; normalized: string; raw: string } + | { kind: 'became-working' } + | { kind: 'became-idle'; title: string } + | { kind: 'agent-exited' } + | { kind: 'bell' } + +type TitleFactPath = { + events: TitleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + const callbacks = { onData: () => {} } + return { + events, + feed(chunk: string): void { + processor.processData(chunk, callbacks) + // Why: the renderer defers side effects behind a setTimeout(0) drain to + // protect xterm paint. Flush synchronously so both paths observe each + // chunk at the same fake-timer instant. + processor.flushPendingSideEffects() + } + } +} + +function createMainPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + // Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999 + // processor strips status payloads before the title tracker sees the chunk. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +type ChunkFeed = { feed: (chunk: string) => void } + +function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void { + paths.renderer.feed(chunk) + paths.main.feed(chunk) +} + +describe('main title tracker parity with the renderer transport processor', () => { + let paths: { renderer: TitleFactPath; main: TitleFactPath } + + beforeEach(() => { + vi.useFakeTimers() + paths = { renderer: createRendererPath(), main: createMainPath() } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { + // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's + // trailing idle title. A last-title reader sees only the idle title and + // never observes the working state. + const chunk = + `${ESC}]0;⠋ π - cwd${BEL}response text\r\n` + + `${ESC}]0;⠙ π - cwd${BEL}more text\r\n` + + `${ESC}]0;π - cwd${BEL}` + feedBoth(paths, chunk) + + expect(paths.main.events).toEqual(paths.renderer.events) + const kinds = paths.main.events.map((event) => event.kind) + expect(kinds).toContain('became-working') + expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) + }) + + it('derives identical facts from BEL- and ST-terminated titles', () => { + feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`) + feedBoth(paths, `${ESC}]0;Codex done${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' }) + }) + + it('drops the bare cursor-agent native title in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + const titles = paths.main.events.filter((event) => event.kind === 'title') + expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }]) + }) + + it('clears a stale working title after the 3s timeout in both paths', () => { + feedBoth(paths, `${ESC}]0;. Claude working${BEL}`) + feedBoth(paths, 'output with no title\r\n') + + vi.advanceTimersByTime(3_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' }) + }) + + it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => { + feedBoth(paths, `${ESC}]0;Codex working${BEL}`) + feedBoth(paths, 'plain output arms the timer\r\n') + + vi.advanceTimersByTime(2_000) + // Why: a chunk that is ONLY an Orca status payload strips to empty + // cleanData; neither path may restart (or newly arm) the stale probe. + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + vi.advanceTimersByTime(1_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' }) + }) + + it('ignores a title split across chunk boundaries in both paths', () => { + feedBoth(paths, `${ESC}]0;split-ti`) + feedBoth(paths, `tle${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) + + it('orders a real BEL after the same chunk titles in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).toEqual([ + 'title', + 'became-working', + 'bell' + ]) + }) + + it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => { + feedBoth(paths, `${ESC}]0;par`) + feedBoth(paths, `tial title${BEL}`) + feedBoth(paths, `${ESC}]2;st-terminated${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([]) + }) + + it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => { + // ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell. + feedBoth(paths, `${ESC}]0;truncated`) + feedBoth(paths, `\x18${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([{ kind: 'bell' }]) + }) + + it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => { + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into +// main's tracker for local/SSH PTYs. Both must derive identical fact +// sequences from the same chunk boundaries, or flipping the kill switch +// changes which commands/links are observed. +type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number] + +type LifecycleFactPath = { + events: LifecycleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + // Why: mirrors pty-connection's dataCallback wiring — the transport + // processor strips OSC 9999 before the lifecycle/PR-link byte scans run. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const lifecycle = createTerminalCommandLifecycle({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]) + }) + const detectPRLinks = createTerminalGitHubPRLinkDetector() + return { + events, + feed(chunk: string): void { + const clean = processAgentStatusChunk(chunk).cleanData + lifecycle.handlePtyData(clean) + for (const link of detectPRLinks(clean)) { + events.push(['pr-link', link.url, link.number]) + } + } + } +} + +function createMainLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]), + onPrLink: (link) => events.push(['pr-link', link.url, link.number]) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => { + let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath } + + beforeEach(() => { + paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() } + }) + + it('derives identical command-finished facts from split OSC 133;D chunks', () => { + feedBoth(paths, `output${ESC}]133`) + feedBoth(paths, ';D;13') + feedBoth(paths, `0${BEL}prompt $ `) + feedBoth(paths, `${ESC}]133;D;0${BEL}`) + feedBoth(paths, `${ESC}]133;D${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['command-finished', 130], + ['command-finished', 0], + ['command-finished', null] + ]) + }) + + it('derives identical pr-link facts from split and repeated URLs', () => { + feedBoth(paths, 'Created https://github.com/acme/orca/pull/4') + feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n') + feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['pr-link', 'https://github.com/acme/orca/pull/42', 42], + ['pr-link', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => { + feedBoth( + paths, + `${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n` + ) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 4 moves the Command Code output scrape into main for local/SSH +// PTYs. The renderer byte path observes raw transport data; main observes the +// OSC 9999-stripped cleanData. Both must derive identical working/done +// sequences from the same chunk boundaries, or flipping the kill switch +// changes Command Code status rows. +type CommandCodeFactEvent = ['working' | 'done', string] + +type CommandCodeFactPath = { + events: CommandCodeFactEvent[] + feed: (chunk: string) => void +} + +function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath { + const events: CommandCodeFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking: (prompt) => events.push(['working', prompt]), + onDone: (prompt) => events.push(['done', prompt]) + }) + return { + events, + feed(chunk: string): void { + detector.observe( + options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk + ) + } + } +} + +describe('main Command Code scrape parity with the renderer byte detector', () => { + let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath } + + beforeEach(() => { + paths = { + renderer: createCommandCodePath({ stripStatusPayloads: false }), + main: createCommandCodePath({ stripStatusPayloads: true }) + } + }) + + it('derives identical working facts after the banner arms across chunks', () => { + feedBoth(paths, '# Command') + feedBoth(paths, ' Code v0.27.3\r\n') + feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([['working', 'Fix the spinner']]) + }) + + it('derives identical done facts for a no-tool turn in both paths', () => { + feedBoth(paths, '# Command Code v0.27.3\r\n') + feedBoth(paths, '❯ say hi\r\n✻ Thinking...') + feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => { + // Deliberate divergence, not drift: the renderer's raw byte path lets an + // OSC 9999 payload leak partial text into the scrape window (its ANSI + // strip consumes only the ESC] introducer), which breaks the prompt-echo + // line match. Main feeds the OSC 9999-stripped cleanData, so the prompt + // (and therefore the done settle hint) survives an adjacent payload. + const payloadThenPrompt = [ + '# Command Code v0.27.3\r\n', + `${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`, + '❯ say hi\r\n✻ Thinking...', + '\r\n:: Hi!\r\n❯ Ask your question...' + ] + for (const chunk of payloadThenPrompt) { + feedBoth(paths, chunk) + } + + expect(paths.renderer.events).toEqual([['working', '']]) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('stays silent without the Command Code banner in both paths', () => { + feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -0,0 +1,249 @@ +/** + * View-attribute bridge publication (terminal-query-authority.md §View- + * attribute bridge): the composed snapshot must mirror xterm ThemeService + * resolution (defaults, cursor blend, 256-entry palette), and pushes must + * happen once per actual change — not per pane, not per font tweak. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { applyTerminalAppearance } from './terminal-appearance' +import { + _resetTerminalViewAttributesPublisherForTest, + composeTerminalViewAttributes, + publishTerminalViewAttributes +} from './terminal-view-attributes-publisher' + +const cursorSettings = { + terminalCursorStyle: 'block' as const, + terminalCursorBlink: true +} + +beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + vi.unstubAllGlobals() +}) + +describe('composeTerminalViewAttributes', () => { + it('resolves a null theme to the xterm ThemeService defaults', () => { + const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + expect(attrs.background).toEqual([0x00, 0x00, 0x00]) + expect(attrs.cursor).toEqual([0xff, 0xff, 0xff]) + expect(attrs.ansi).toHaveLength(256) + // DEFAULT_ANSI_COLORS parity: named 16, color cube, greys. + expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36]) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec]) + expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00]) + expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00]) + expect(attrs.ansi[232]).toEqual([8, 8, 8]) + expect(attrs.ansi[255]).toEqual([238, 238, 238]) + expect(attrs.colorSchemeMode).toBe('dark') + expect(attrs.cursorStyle).toBe('block') + expect(attrs.cursorBlink).toBe(true) + }) + + it('parses composed theme colors including rgba() opacity forms', () => { + const attrs = composeTerminalViewAttributes( + { + // composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity + // or terminalCursorOpacity apply; the reply drops alpha like xterm's + // toColorRGB, except the cursor which blends over the background. + background: 'rgba(30, 30, 46, 0.9)', + foreground: '#d0d0d0', + cursor: 'rgba(255, 0, 0, 0.5)', + red: '#ff8800' + }, + 'light', + { terminalCursorStyle: 'underline', terminalCursorBlink: false } + ) + expect(attrs.background).toEqual([30, 30, 46]) + expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0]) + // color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a). + expect(attrs.cursor).toEqual([143, 15, 23]) + expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00]) + expect(attrs.colorSchemeMode).toBe('light') + expect(attrs.cursorStyle).toBe('underline') + expect(attrs.cursorBlink).toBe(false) + }) + + it('keeps an opaque cursor un-blended and blends short-hex alpha', () => { + const attrs = composeTerminalViewAttributes( + { background: '#000000', cursor: '#ff0000' }, + 'dark', + cursorSettings + ) + expect(attrs.cursor).toEqual([255, 0, 0]) + + const blended = composeTerminalViewAttributes( + { background: '#000000', cursor: '#f00a' }, + 'dark', + cursorSettings + ) + // #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170. + expect(blended.cursor).toEqual([170, 0, 0]) + }) + + it('overlays extendedAnsi onto the default 256 palette tail', () => { + const attrs = composeTerminalViewAttributes( + { extendedAnsi: ['#102030'] }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30]) + // Untouched tail entries stay on the generated cube. + expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f]) + }) + + it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => { + // A visible pane resolves named CSS via canvas; the composer cannot, so + // hand-edited values fall back — the documented divergence boundary. + const attrs = composeTerminalViewAttributes( + { red: 'darkred', foreground: 'hotpink' }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + }) +}) + +describe('publishTerminalViewAttributes dedupe', () => { + it('publishes once per snapshot change, not per call', () => { + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false) + expect(send).toHaveBeenCalledTimes(1) + + // A real attribute change (theme flip) publishes again. + expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not record a failed send, so the next call retries', () => { + const failingSend = vi.fn(() => false) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false) + + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + }) + + it('skips silently when the preload bridge is unavailable (web client, tests)', () => { + // No window stub: default send must be a safe no-op. + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false) + }) +}) + +describe('applyTerminalAppearance publication', () => { + function makePane(id: number): ManagedPane { + return { + id, + terminal: { options: {}, cols: 80, rows: 24 } + } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function stubPublishBridge(): ReturnType { + const publish = vi.fn<(attributes: TerminalViewAttributes) => void>() + vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } }) + return publish + } + + it('pushes the app-global snapshot once per change, not per pane or per manager', () => { + const publish = stubPublishBridge() + const settings = getDefaultSettings('/tmp') + + // Two panes in one manager plus a second manager (another tab): the + // attributes are app-global, so identical applies publish exactly once. + applyTerminalAppearance( + makeManager([makePane(1), makePane(2)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(3)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + const attributes = publish.mock.calls[0][0] as TerminalViewAttributes + expect(attributes.ansi).toHaveLength(256) + expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle) + + // Attribute-neutral tweak (font size) must not re-push… + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalFontSize: settings.terminalFontSize + 2 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + + // …while a cursor-style change is a real attribute change. + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalCursorStyle: 'underline' }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('publishes the resolved color-scheme mode flip (system dark toggle)', () => { + const publish = stubPublishBridge() + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const modes = publish.mock.calls.map( + (call) => (call[0] as TerminalViewAttributes).colorSchemeMode + ) + expect(modes).toEqual(['dark', 'light']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..304ff399f6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -0,0 +1,251 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * the reply-relevant slots of the active terminal theme exactly the way + * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, + * 256-entry palette), so main's hidden-PTY responder replies byte-identically + * to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance + * runs per pane manager and on every font/opacity tweak, but the attributes + * are app-global, so identical snapshots publish once. + */ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../../shared/terminal-view-attributes' + +type ParsedCssColor = { + rgb: TerminalViewRgb + /** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */ + alpha: number +} + +// ThemeService defaults for the reply-relevant slots (browser/services/ +// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff. +const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } +const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff } +const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } + +// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts). +const DEFAULT_ANSI_16: readonly string[] = [ + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +] + +const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +function buildDefaultAnsiPalette(): TerminalViewRgb[] { + const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb) + // 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's + // DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically. + const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + for (let i = 0; i < 216; i++) { + palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]]) + } + for (let i = 0; i < 24; i++) { + const c = 8 + i * 10 + palette.push([c, c, c]) + } + return palette +} + +const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette() + +/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(), + * rgba()) — every format first-party inputs produce (builtin themes and the + * ghostty import are hex-validated; composeActiveTerminalTheme only adds the + * rgba() form this regex accepts). Known divergence boundary: the renderer's + * css.toColor also resolves named/modern CSS via a canvas litmus, so a + * hand-edited settings value like `background: 'darkslategray'` renders on + * a visible pane but falls back to the slot default in the hidden reply. */ +export function parseCssColor(css: string): ParsedCssColor | null { + if (/^#[\da-f]{3,8}$/i.test(css)) { + switch (css.length) { + case 4: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: 0xff + } + case 5: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: Number.parseInt(css.slice(4, 5).repeat(2), 16) + } + case 7: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: 0xff + } + case 9: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: Number.parseInt(css.slice(7, 9), 16) + } + default: + return null + } + } + const rgbaMatch = css.match( + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/ + ) + if (rgbaMatch) { + return { + rgb: [ + Number.parseInt(rgbaMatch[1], 10), + Number.parseInt(rgbaMatch[2], 10), + Number.parseInt(rgbaMatch[3], 10) + ], + alpha: Math.round((rgbaMatch[5] === undefined ? 1 : Number.parseFloat(rgbaMatch[5])) * 0xff) + } + } + return null +} + +function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor { + if (css !== undefined) { + const parsed = parseCssColor(css) + if (parsed) { + return parsed + } + } + return fallback +} + +// Mirror of xterm's color.blend: ThemeService blends the cursor color's +// alpha over the background at theme-set time (terminalCursorOpacity), and +// the OSC 12 reply reports the blended value. +function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb { + if (color.alpha === 0xff) { + return color.rgb + } + const a = color.alpha / 0xff + return [ + background[0] + Math.round((color.rgb[0] - background[0]) * a), + background[1] + Math.round((color.rgb[1] - background[1]) * a), + background[2] + Math.round((color.rgb[2] - background[2]) * a) + ] +} + +export function composeTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick +): TerminalViewAttributes { + const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND) + const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND) + const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR) + const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => { + const value = theme?.[key] + return parseThemeColor(typeof value === 'string' ? value : undefined, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + }) + for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) { + const extended = theme?.extendedAnsi?.[i - 16] + ansi.push( + parseThemeColor(extended, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + ) + } + return { + foreground: foreground.rgb, + background: background.rgb, + cursor: blendOverBackground(background.rgb, cursor), + ansi, + colorSchemeMode: mode, + // Same resolution as the per-pane option writes in applyTerminalAppearance. + cursorStyle: settings.terminalCursorStyle ?? 'block', + cursorBlink: settings.terminalCursorBlink === true + } +} + +let lastPublishedSnapshot: string | null = null + +function sendViaPreload(attributes: TerminalViewAttributes): boolean { + // Guarded: unit tests and the web client run without the preload bridge + // (remote-runtime PTYs are never hidden-gate markable anyway). + if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) { + return false + } + window.api.pty.publishTerminalViewAttributes(attributes) + return true +} + +/** Publishes the composed app-global attributes, once per actual change: + * repeat calls from per-pane appearance applies (and attribute-neutral + * tweaks like font size) are deduped against the last published snapshot. */ +export function publishTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick, + send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload +): boolean { + const attributes = composeTerminalViewAttributes(theme, mode, settings) + const serialized = JSON.stringify(attributes) + if (serialized === lastPublishedSnapshot) { + return false + } + if (!send(attributes)) { + // Not recorded: a later call with a working bridge must still publish. + return false + } + lastPublishedSnapshot = serialized + return true +} + +/** Test seam: reset the dedupe state between tests. */ +export function _resetTerminalViewAttributesPublisherForTest(): void { + lastPublishedSnapshot = null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts new file mode 100644 index 00000000000..8fbb426ade8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +// Why: lib-layer WebGL code records through the shared sink because it may not +// import the components-layer ring. Importing terminal-freeze-breadcrumbs wires +// that sink to the ring at module load; this pins that the WebGL crumbs land in +// the same one-paste report as delivery/visibility history. +describe('WebGL diagnostics → freeze breadcrumb ring', () => { + beforeEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + afterEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + it('routes context-loss and atlas-reset crumbs into the freeze report ring', () => { + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: 3 }) + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: 1 }) + + const crumbs = getTerminalFreezeBreadcrumbs() + expect(crumbs.map((crumb) => crumb.kind)).toEqual(['webgl-context-loss', 'webgl-atlas-reset']) + expect(crumbs[0]?.detail).toEqual({ paneId: 3 }) + expect(crumbs[1]?.detail).toEqual({ managers: 1 }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index e021ced6b6a..09033df6264 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -21,6 +21,10 @@ import { type TerminalHiddenReason } from './terminal-visibility-resume' import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -50,7 +54,7 @@ function reportRendererPtyVisibility( // renderer-visibility registry, so reporting them here is misleading. continue } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } } @@ -122,7 +126,11 @@ export function useTerminalPaneGlobalEffects({ useEffect(() => { const paneTransports = paneTransportsRef.current reportRendererPtyVisibility(paneTransports, rendererVisible) - return () => reportRendererPtyVisibility(paneTransports, false) + return () => { + for (const transport of paneTransports.values()) { + releaseRendererPtyVisibilityClaim(transport) + } + } }, [rendererVisible, paneTransportsRef]) useEffect(() => { 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 7999db503ca..b0e2cd10ef8 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 @@ -15,6 +15,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' +import { configureTerminalOutputBacklogCap } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' @@ -65,6 +66,7 @@ import { import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' +import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { @@ -107,6 +109,7 @@ import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -559,6 +562,10 @@ export function useTerminalPaneLifecycle({ const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows ) + // Why here: the output scheduler's backlog cap scales with the same + // scrollback setting; applying it where the setting is read keeps the two + // in lockstep without a separate settings subscription. + configureTerminalOutputBacklogCap(settings?.terminalScrollbackRows) const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const previousVisibleForReconcileRef = useRef(null) @@ -764,6 +771,13 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + // Why: a DECSET 2031 subscribe answered from main's fact channel must + // land in the same registries the xterm CSI handler writes — otherwise + // theme flips never push CSI 997 and the TUI keeps a stale theme. + recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, repliedMode) + }, restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {} } @@ -791,7 +805,19 @@ export function useTerminalPaneLifecycle({ const mode2031Disposables = installMode2031Handlers({ paneId: pane.id, parser: pane.terminal.parser, - onSubscribe: () => pushMode2031ForPane(pane.id), + onSubscribe: () => { + // Why: for hidden-delivery-gate-managed PTYs main's + // '2031-subscribe' fact is the sole responder — bytes reaching + // xterm live (foreground, sidecar interest) must not produce a + // second reply. The CSI handler still records the subscription. + const binding = panePtyBindings.get(pane.id) as + | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean }) + | undefined + if (binding?.isHiddenDeliveryGateManagedPty?.()) { + return + } + pushMode2031ForPane(pane.id) + }, isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), paneMode2031: paneMode2031Ref.current, paneLastThemeMode: paneLastThemeModeRef.current @@ -805,12 +831,15 @@ export function useTerminalPaneLifecycle({ // both the enabled and disabled paths so xterm doesn't fall // through to any other OSC 52 handler and so our intentional drop // in the disabled path is explicit. - const osc52Disposable = pane.terminal.parser.registerOscHandler(52, (data) => - handleOsc52ClipboardRequest(data, { - allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, - writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast - }) + const osc52Disposable = pane.terminal.parser.registerOscHandler( + 52, + guardParserHandler('osc-52-clipboard', (data) => + handleOsc52ClipboardRequest(data, { + allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, + writeClipboardText: window.api.ui.writeClipboardText, + onBlockedWrite: showOsc52ClipboardBlockedToast + }) + ) ) osc52DisposablesRef.current.set(pane.id, osc52Disposable) @@ -836,14 +865,17 @@ export function useTerminalPaneLifecycle({ confirmed: false }) } - const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { - const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) - if (parsedCwd) { - const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) - paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) - } - return true - }) + const osc7Disposable = pane.terminal.parser.registerOscHandler( + 7, + guardParserHandler('osc-7-cwd', (data) => { + const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) + if (parsedCwd) { + const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) + paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) + } + return true + }) + ) osc7DisposablesRef.current.set(pane.id, osc7Disposable) // Why: let host-handled keys bypass xterm's kitty CSI-u encoder. @@ -1722,6 +1754,19 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } imeNativeTextForwarderDisposables.clear() + // Why: hidden-view parking starts pane-less byte watchers right after + // this unmount; record pane identities before transports detach so the + // watchers write the same runtime-title slots the live panes used. + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((capturedPane) => ({ + ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, + paneId: capturedPane.id, + leafId: capturedPane.leafId, + drivesTabTitle: manager.getActivePane()?.id === capturedPane.id + })) + ) for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts new file mode 100644 index 00000000000..190a5fd5a27 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -0,0 +1,243 @@ +/** + * Per-tab hidden-view parking for TerminalPaneOverlayLayer. + * + * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck + * timers, parked-set selection) and the parked byte-watcher reconciliation so + * the overlay layer only consumes the final parked tab set when deciding to + * render a slot as null. See docs/reference/terminal-hidden-view-parking.md. + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from './terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +type TerminalOverlayTabAssignment = { + groupId: string + isActiveInGroup: boolean +} + +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + +export function useTerminalTabColdParking(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + /** Worktree-level park verdict from Terminal.tsx. */ + coldParkTerminalPanes: boolean + /** Hidden-measuring startup probe from Terminal.tsx — the panes must stay + * mounted for their first xterm fit, mirroring the worktree-level guard. */ + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + } = args + const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore( + (state) => state.settings?.terminalHiddenViewParking !== false + ) + const terminalTabHiddenSinceRef = useRef(new Map()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => new Set() + ) + + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { + if (!currentTerminalTabIds.has(tabId)) { + terminalTabHiddenSinceRef.current.delete(tabId) + } + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + // Why: hidden-measuring counts as visibility — the startup probe needs + // mounted panes, so the hidden clock must not run during it. + if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) { + terminalTabHiddenSinceRef.current.delete(terminalTab.id) + } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) { + terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null + } + }) + + const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a tab the byte watchers cannot cover (no capture, no layout + // snapshot, legacy leaf ids) must never park — it would go silent for + // bells/titles/completions, the failure that sank the first attempt. + for (const terminalTab of terminalTabs) { + if ( + nextColdParkedTerminalTabIds.has(terminalTab.id) && + !canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) + ) { + nextColdParkedTerminalTabIds.delete(terminalTab.id) + } + } + setColdParkedTerminalTabIds((current) => + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextColdParkedTerminalTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setTerminalTabParkingRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pendingStartupByTabId, + shouldMeasureHiddenWorktree, + terminalParkingEnabled, + terminalTabParkingRevision, + terminalTabs, + worktreeId + ]) + + // Why: the rendered park verdict — worktree-level park (prop from + // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and + // the watcher-sync effect must share this exact set so watcher lifecycle + // tracks the committed unmounts. + const parkedTerminalTabIds = useMemo(() => { + const parked = new Set() + for (const terminalTab of terminalTabs) { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) !== null + if ( + (coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) && + !hasActivityTerminalPortal && + // Why: the hidden-measuring startup probe needs mounted panes; gate + // here too so the reveal lands in the same render that starts it. + !shouldMeasureHiddenWorktree + ) { + parked.add(terminalTab.id) + } + } + return parked + }, [ + activityTerminalPortals, + assignments, + coldParkTerminalPanes, + coldParkedTerminalTabIds, + isWorktreeActive, + shouldMeasureHiddenWorktree, + terminalTabs, + worktreeId + ]) + + // Why: runs in the same effect flush as the commit that parked/revealed the + // panes — watcher disposal therefore lands before any PTY data IPC can + // reach a freshly remounted pane, and watcher start lands after the parked + // pane's unmount capture. + useEffect(() => { + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds: parkedTerminalTabIds + }) + }, [parkedTerminalTabIds, terminalTabs, worktreeId]) + + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + + return parkedTerminalTabIds +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts new file mode 100644 index 00000000000..c83cdde9288 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' + +const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn() +})) + +vi.mock('./terminal-visibility-resume', () => ({ + recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock +})) + +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +describe('useTerminalWindowWakeRecovery', () => { + const manager = {} as PaneManager + let systemResumedCallback: (() => void) | null = null + const unsubscribeSystemResumed = vi.fn() + const onSystemResumed = vi.fn((callback: () => void) => { + systemResumedCallback = callback + return unsubscribeSystemResumed + }) + + beforeEach(() => { + systemResumedCallback = null + recoverVisibleTerminalWindowWakeMock.mockClear() + unsubscribeSystemResumed.mockClear() + onSystemResumed.mockClear() + resetTerminalFreezeBreadcrumbsForTesting() + // Why: without requestAnimationFrame the hook skips its settled-frame + // follow-up, so every trigger maps to exactly one synchronous recovery. + vi.stubGlobal('requestAnimationFrame', undefined) + ;(window as unknown as { api: unknown }).api = { ui: { onSystemResumed } } + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete (window as unknown as { api?: unknown }).api + }) + + function renderWakeRecoveryHook(isVisible = true) { + return renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + } + + it('clears the glyph atlas on system resume but not on plain window focus', () => { + // Why: wiping the shared WebGL glyph atlas on a plain refocus provokes + // xterm's page-merge race and paints garbled glyphs (#7604). Only a genuine + // OS resume — which can leave a stale renderer surface — clears the atlas. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(1, { + manager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(systemResumedCallback).toBeTypeOf('function') + systemResumedCallback?.() + + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(2) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(2, { + manager, + isActive: true, + clearGlyphAtlases: true + }) + }) + + it('records a wake-recovery breadcrumb with the trigger source and atlas decision', () => { + // Why: a post-wake garble report attributes to the trigger that ran (or its + // absence). Pin that focus records source=focus/atlas=false and system + // resume records source=system-resumed/atlas=true. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + systemResumedCallback?.() + + const wakeCrumbs = getTerminalFreezeBreadcrumbs().filter((crumb) => + crumb.kind.startsWith('wake-recovery:') + ) + expect(wakeCrumbs.map((crumb) => [crumb.kind, crumb.detail])).toEqual([ + ['wake-recovery:focus', { clearGlyphAtlases: false }], + ['wake-recovery:system-resumed', { clearGlyphAtlases: true }] + ]) + }) + + it('unsubscribes from the system resume event on cleanup', () => { + const { unmount } = renderWakeRecoveryHook() + expect(onSystemResumed).toHaveBeenCalledTimes(1) + + unmount() + + expect(unsubscribeSystemResumed).toHaveBeenCalledTimes(1) + }) + + it('does not subscribe while the terminal surface is hidden', () => { + renderWakeRecoveryHook(false) + + expect(onSystemResumed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 8c5da7ed97b..839b6184696 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' type UseTerminalWindowWakeRecoveryArgs = { isVisible: boolean @@ -29,7 +30,19 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } - const recoverVisibleWake = (clearGlyphAtlases: boolean): void => { + const recoverVisibleWake = ( + clearGlyphAtlases: boolean, + source: 'focus' | 'visibilitychange' | 'system-resumed' + ): void => { + // Why: the decisive crumb for a post-wake garble report — which trigger + // fired and whether it wiped the atlas. If the report shows a stale pane + // but NO wake crumb near the unlock time, the trigger never fired (the + // unlock-screen gap); a crumb with clearGlyphAtlases=false means the light + // path ran but may not have healed a corrupted atlas. Silent (memory ring). + // Source is in the kind so distinct triggers don't coalesce into one + // entry (focus and resume often fire together); repeats of the same + // source still fold, which is the noise control we want. + recordTerminalFreezeBreadcrumb(`wake-recovery:${source}`, { clearGlyphAtlases }) // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. if (wakeRecoveryFrameId !== null) { // Why: a pending settled pass may only upgrade in strength — a plain @@ -69,23 +82,27 @@ export function useTerminalWindowWakeRecovery({ // an agent streams; wiping the shared glyph atlas then provokes xterm's // page-merge race and paints garbled glyphs. Focus recovery keeps the warm // atlas: it only retries WebGL attach, refits, and repaints pane-scoped. - const onFocus = (): void => recoverVisibleWake(false) + const onFocus = (): void => recoverVisibleWake(false, 'focus') const onVisibilityChange = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'visibilitychange') } } // Why: Linux has no window-occlusion tracking, so visibilitychange never // fires around system suspend; the main process broadcasts OS resume. const onSystemResumed = (): void => { if (typeof document === 'undefined' || document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'system-resumed') } } window.addEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + // Why: a focus-preserving display wake fires neither focus nor + // visibilitychange, so main relays powerMonitor resume over IPC. Genuine + // wake clears the WebGL glyph atlas (clearGlyphAtlases=true via + // onSystemResumed) — the latch-clearing recovery — unlike plain refocus. const unsubscribeSystemResumed = typeof window.api?.ui?.onSystemResumed === 'function' ? window.api.ui.onSystemResumed(onSystemResumed) diff --git a/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts new file mode 100644 index 00000000000..084b74edcc3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts @@ -0,0 +1,79 @@ +/** + * Repro for the frozen-terminal investigation (Discord #performance / #2836). + * + * The vendored xterm WriteBuffer (6.1.0-beta.287) permanently wedges when a + * synchronous exception escapes a write-completion callback: `_innerWrite` + * has no try/catch around `cb()`, the tail `_scheduleInnerWrite()` never + * runs, and later `write()` calls only re-schedule processing when the + * buffer is EMPTY — which a stalled buffer never is again. + * + * In Orca, write-completion callbacks run settleForegroundRender → refresh → + * renderer/WebGL code (pane-terminal-foreground-render-settle.ts) and the + * replay-guard decrement (replay-guard.ts). So one renderer exception during + * write completion freezes that pane's output forever AND latches the replay + * guard, whose gate in pty-connection.ts onData then silently drops every + * keystroke — the exact live-shell/flat-output.log/frozen-pane state the + * field reports describe. @xterm/headless shares the same WriteBuffer as + * @xterm/xterm at the same pinned version. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' + +describe('xterm WriteBuffer stall (vendored 6.1.0-beta.287)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('permanently stops completing writes after a sync throw in a write-completion callback', () => { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + + term.write('first', () => { + completed.push('first') + throw new Error('synthetic renderer failure during write completion') + }) + term.write('second', () => { + completed.push('second') + }) + + expect(() => vi.runAllTimers()).toThrow('synthetic renderer failure') + + // The wedge: the stalled buffer is never empty again, so new writes only + // enqueue — no drain is ever scheduled and no callback ever fires. + term.write('third', () => { + completed.push('third') + }) + vi.runAllTimers() + expect(completed).toEqual(['first']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('permanently stops completing writes after a sync throw in a custom parser handler', () => { + // Orca registers custom CSI/OSC handlers (capability replies, titles, + // agent status). The parser does NOT isolate sync handler exceptions: + // they escape _action and wedge the buffer exactly like the callback + // case — custom handlers are a second freeze vector. + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler({ final: 'z' }, () => { + throw new Error('synthetic parser handler failure') + }) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).toThrow('synthetic parser handler failure') + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 31c51256a88..cbf24b7c1a6 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -67,6 +67,10 @@ declare global { interface Window { __paneManagers?: Map __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps + __terminalParkingDebug?: { + parkDelayMs: number + parkedTabIds: () => string[] + } } } diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts new file mode 100644 index 00000000000..6c8efbf81cf --- /dev/null +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockSubscribeTerminal = vi.fn() +const mockCallRuntimeRpc = vi.fn() + +const state = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, + setAgentStatus: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state + } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mockCallRuntimeRpc, + getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })) +})) + +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal }) +})) + +const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07' + +describe('observeExistingAutomationSession', () => { + beforeEach(() => { + vi.clearAllMocks() + state.settings = { + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + mockSubscribeToPtyData.mockReturnValue(vi.fn()) + mockSubscribeToPtyExit.mockReturnValue(vi.fn()) + mockCallRuntimeRpc.mockReturnValue(new Promise(() => {})) + mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() }) + }) + + it('skips the duplicate OSC store write for local PTYs under main authority', async () => { + // Why: main already parses OSC 9999 for local/SSH PTYs and routes it to + // the store via agentStatus:set; writing here too would race that path. + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + + it('keeps the legacy OSC store write when the kill switch is off', async () => { + state.settings.terminalMainSideEffectAuthority = false + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) + + it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => { + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'remote:env-1@@terminal-9', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1) + const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as { + onData: (data: string) => void + } + callbacks.onData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index c80ae9156e8..9976468ed69 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -10,6 +10,7 @@ import { import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' export async function observeExistingAutomationSession(args: { ptyId: string @@ -20,12 +21,25 @@ export async function observeExistingAutomationSession(args: { onExit: (code: number) => void }): Promise<() => void> { const { ptyId, paneKey, runId, onData, onExit } = args + // Why: for local/SSH PTYs main already parses OSC 9999 and routes it + // through the hook server (agentStatus:set → store); writing here too + // would race/duplicate that path. Remote-runtime bytes never transit local + // main, and the kill switch restores the legacy write. The onAgentStatus + // callback always fires — automation completion tracking stays here. + const mainOwnsAgentStatusWrites = + !isRemoteRuntimePtyId(ptyId) && + isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { onData(data) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + } args.onAgentStatus(payload) } } diff --git a/src/renderer/src/lib/crash-breadcrumb-recorder.ts b/src/renderer/src/lib/crash-breadcrumb-recorder.ts new file mode 100644 index 00000000000..c4a646ea01c --- /dev/null +++ b/src/renderer/src/lib/crash-breadcrumb-recorder.ts @@ -0,0 +1,25 @@ +import type { CrashReportBreadcrumbData } from '../../../shared/crash-reporting' + +// Why a leaf module: terminal modules (replay-guard, output scheduler, parser +// guards) record breadcrumbs, and e2e specs import those modules' constants — +// Playwright loads spec imports at collection time under a transform that +// cannot handle crash-diagnostics.ts (top-level `import.meta.hot` plus the +// webview-registry import chain). Keep this file free of value imports and +// import.meta so it stays loadable from any context. + +/** Best-effort breadcrumb recording; must never create or mask failures. */ +export function recordRendererCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData +): void { + if (typeof window === 'undefined') { + return + } + + try { + const api = (window as Window & { api?: Window['api'] }).api + api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) + } catch { + // Best-effort crash evidence only. + } +} diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index aa7ec860ef6..bb0875f1433 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -3,6 +3,7 @@ import type { CrashReportDetailValue } from '../../../shared/crash-reporting' import { getBrowserWebviewMemoryProfile } from '../components/browser-pane/webview-registry' +import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 @@ -16,22 +17,10 @@ type BrowserPerformanceMemory = { let rendererCrashDiagnosticsInstalled = false let rendererMemoryInterval: number | null = null -export function recordRendererCrashBreadcrumb( - name: string, - data?: CrashReportBreadcrumbData -): void { - if (typeof window === 'undefined') { - return - } - - try { - // Why: crash diagnostics must never create or mask renderer startup failures. - const api = (window as Window & { api?: Window['api'] }).api - api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) - } catch { - // Best-effort crash evidence only. - } -} +// Why re-exported from a leaf module: terminal modules and their e2e-visible +// import chains need breadcrumb recording without this file's import.meta / +// webview-registry baggage. See crash-breadcrumb-recorder.ts. +export { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' export function installRendererCrashDiagnostics(): void { if (rendererCrashDiagnosticsInstalled || typeof window === 'undefined') { diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 05ea8c812f5..34a53c0494e 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,19 +1,20 @@ +// Why: the parsing core moved to shared so main's terminal side-effect +// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3). +// Re-exported here so renderer consumers keep their '@/lib' import path. +// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link- +// picker input policy, not parsing. +import { + type GitHubIssueOrPRLink, + parseGitHubIssueOrPRLink, + parseGitHubIssueOrPRNumber +} from '../../../shared/github-links' + import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' -const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i +export * from '../../../shared/github-links' + const HTTP_URL_PREFIX_RE = /^https?:\/\//i -export type RepoSlug = { - owner: string - repo: string -} - -export type GitHubIssueOrPRLink = { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} - export type GitHubLinkQuery = { query: string directNumber: number | null @@ -21,93 +22,6 @@ export type GitHubLinkQuery = { tooLarge?: boolean } -export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { - if (!slug?.owner || !slug.repo) { - return null - } - return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` -} - -function matchGitHubItemPath(url: URL): RegExpExecArray | null { - return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) -} - -function parseGitHubItemNumber(value: string): number | null { - const parsed = Number.parseInt(value, 10) - return parsed > 0 ? parsed : null -} - -/** - * Parses a GitHub issue/PR reference from plain input. - * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. - */ -export function parseGitHubIssueOrPRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return parseGitHubItemNumber(numeric) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return parseGitHubItemNumber(match[4]) -} - -/** - * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns - * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. - */ -export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - const number = parseGitHubItemNumber(match[4]) - if (number === null) { - return null - } - - return { - slug: { owner: match[1], repo: match[2] }, - type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', - number - } -} - /** * Normalizes link-picker input so both raw issue/PR numbers and full GitHub * URLs resolve to a usable query + direct-number lookup. diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2a089f8411c..96f01753116 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -38,7 +38,11 @@ function expectStablePaneSpawn(): string { const state = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, projects: [ { id: 'repo-1', @@ -110,7 +114,11 @@ describe('launchAgentBackgroundSession', () => { ) state.activeRepoId = 'repo-1' state.activeWorktreeId = 'wt-1' - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } state.projects = [ { id: 'repo-1', @@ -339,7 +347,10 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalled() }) - it('parses agent status from hidden PTY output', async () => { + it('parses agent status from hidden PTY output when the kill switch is off', async () => { + // Why: with main side-effect authority disabled, this sidecar is the only + // OSC 9999 → store path for hidden local sessions. + state.settings.terminalMainSideEffectAuthority = false const onAgentStatus = vi.fn() const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -367,6 +378,29 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('skips the duplicate OSC store write under main side-effect authority', async () => { + // Why: main already routes OSC 9999 through the hook server to the store + // (agentStatus:set); a second write here would race the authoritative + // path. The automation onAgentStatus callback must still fire. + const onAgentStatus = vi.fn() + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onAgentStatus + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + it('seeds a working status for Command Code prompt launches', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -530,7 +564,8 @@ describe('launchAgentBackgroundSession', () => { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] state.settings = { agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -591,7 +626,11 @@ describe('launchAgentBackgroundSession', () => { }) it('creates background sessions on the active runtime environment', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -663,7 +702,11 @@ describe('launchAgentBackgroundSession', () => { }) it('closes a created runtime terminal when its data subscription fails', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 85a8f1171d7..ccdbd5fa16d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -37,6 +37,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' function runBestEffortCleanup(action: () => void): void { try { @@ -163,6 +164,12 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearAgentLaunchConfig(paneKey) onExit?.(exitPtyId, code) } + // Why: local/SSH status facts already pass through main's authoritative + // scanner; remote-runtime bytes still need this renderer-side store write. + const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ + settings: store.settings, + runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { data = sshStartupDelivery.handleData(data) @@ -170,9 +177,11 @@ export async function launchAgentBackgroundSession( sshStartupDelivery.schedule(ptyId) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { - launchToken - }) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { + launchToken + }) + } onAgentStatus?.(payload) } } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 5fa2be90a20..2a52b31ba78 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -1,7 +1,11 @@ +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import type { PaneRenderingDiagnostics } from './pane-manager-types' + type RegisteredPaneManager = { resetWebglTextureAtlases(): void fitAllPanes?: () => void refreshAllPanes?: () => void + getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] } const liveManagers = new Set() @@ -35,6 +39,9 @@ export function resetAllTerminalWebglAtlases(): void { } export function resetAndRefreshAllTerminalWebglAtlases(): void { + // Why: the atlas wipe is the heavy recovery path; recording it lets a freeze + // report show whether a post-wake repaint actually ran. Silent breadcrumb. + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: liveManagers.size }) const resetManagers: RegisteredPaneManager[] = [] for (const manager of liveManagers) { try { @@ -55,6 +62,27 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void { } } +/** + * Per-pane WebGL renderer state across all live managers, for the one-paste + * freeze report. Lets a post-wake garble report show, per pane, whether it + * held a live WebGL addon or had fallen back after a context loss — the state + * that distinguishes "missed repaint" from "atlas corrupted". + */ +export function getAllPaneRenderingDiagnostics(): PaneRenderingDiagnostics[] { + const all: PaneRenderingDiagnostics[] = [] + for (const manager of liveManagers) { + try { + const diagnostics = manager.getRenderingDiagnostics?.() + if (diagnostics) { + all.push(...diagnostics) + } + } catch { + // Why: best-effort during teardown; one manager must not sink the report. + } + } + return all +} + export function refitAndRefreshAllTerminalPanes(): void { for (const manager of liveManagers) { try { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 93b848e7450..eab1caa497d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,3 +1,5 @@ +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' + export type ForegroundTerminalOutputTarget = { buffer?: { active?: { @@ -131,18 +133,24 @@ export function writeForegroundTerminalChunk( const beforeWriteViewport = options.forceViewportRefresh ? captureViewportSnapshot(terminal) : null - try { - terminal.write(data, () => { - if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) - } - options.onParsed?.() - }) - } catch { + // Why guarded steps: this callback runs inside xterm's WriteBuffer loop, + // where an escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). Guard settle and onParsed separately so a + // renderer/WebGL failure during settle can't starve the replay-guard release. + const runCompletionSteps = (): void => { if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) + runGuardedWriteCompletionStep('foreground-render-settle', () => + settleForegroundRender(terminal, beforeWriteViewport, options) + ) } - options.onParsed?.() + if (options.onParsed) { + runGuardedWriteCompletionStep('foreground-on-parsed', options.onParsed) + } + } + try { + terminal.write(data, runCompletionSteps) + } catch { + runCompletionSteps() } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts new file mode 100644 index 00000000000..a245f2d82b9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts @@ -0,0 +1,45 @@ +type TerminalOutputAckTarget = object + +const inFlightAckCompletions = new WeakMap void>>() + +/** Tracks credits after submission to xterm so pane disposal can treat its + * unparsed write buffer as discarded instead of leaking main's ACK window. */ +export function registerTerminalOutputAckCredits( + terminal: TerminalOutputAckTarget, + credits: readonly (() => void)[] +): (() => void) | undefined { + if (credits.length === 0) { + return undefined + } + let completions = inFlightAckCompletions.get(terminal) + if (!completions) { + completions = new Set() + inFlightAckCompletions.set(terminal, completions) + } + let completed = false + const complete = (): void => { + if (completed) { + return + } + completed = true + completions?.delete(complete) + if (completions?.size === 0) { + inFlightAckCompletions.delete(terminal) + } + for (const credit of credits) { + credit() + } + } + completions.add(complete) + return complete +} + +export function discardInFlightTerminalOutputAckCredits(terminal: TerminalOutputAckTarget): void { + const completions = inFlightAckCompletions.get(terminal) + if (!completions) { + return + } + for (const complete of completions) { + complete() + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts new file mode 100644 index 00000000000..77ca783af6c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Benchmark harness for the terminal performance initiative: measures the +// scheduler-imposed drain ceiling in isolation. A mock terminal parses +// instantly, so the measured rate is pure scheduler drip (writes-per-tick x +// chunk size / reschedule interval). Baseline-jul02 measured agent-tui at +// 2.0 MB/s end-to-end while bare xterm parses the same bytes at ~103 MB/s; +// this pins how much of that ceiling the drain loop itself imposes. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +vi.mock('@/lib/e2e-config', () => ({ + e2eConfig: { exposeStore: false } +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +const TOTAL_CHARS = 4 * 1024 * 1024 +const FEED_CHUNK_CHARS = 8 * 1024 +const MAX_SIMULATED_MS = 60_000 + +function createInstantParseTerminal() { + let written = 0 + return { + get written() { + return written + }, + buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } }, + rows: 24, + refresh: vi.fn(), + _core: { refresh: vi.fn() }, + write: vi.fn((data: string, callback?: () => void) => { + written += data.length + callback?.() + }) + } +} + +async function loadScheduler() { + vi.resetModules() + return import('./pane-terminal-output-scheduler') +} + +async function measure(options: { foreground: boolean }): Promise { + vi.useFakeTimers() + const scheduler = await loadScheduler() + const terminal = createInstantParseTerminal() + const payload = 'x'.repeat(FEED_CHUNK_CHARS) + // Why paced feeding: dumping the whole payload trips the backlog cap + // (replaceBacklogWithWarning). Real sources are paced by main's 512KB + // delivery watermark; keep in-flight below a 256KB window like a live PTY. + const IN_FLIGHT_WINDOW_CHARS = 256 * 1024 + let fed = 0 + let elapsed = 0 + while (terminal.written < TOTAL_CHARS && elapsed < MAX_SIMULATED_MS) { + while (fed < TOTAL_CHARS && fed - terminal.written < IN_FLIGHT_WINDOW_CHARS) { + scheduler.writeTerminalOutput(terminal as never, payload, { + foreground: options.foreground, + // Why false: floods are classified latency-insensitive by + // pty-connection's isLatencySensitiveForegroundOutput once the + // immediate budget is spent — this is the sustained-throughput path. + latencySensitive: false + }) + fed += FEED_CHUNK_CHARS + } + vi.advanceTimersByTime(1) + elapsed += 1 + } + expect(terminal.written).toBe(TOTAL_CHARS) + return TOTAL_CHARS / 1024 / 1024 / (elapsed / 1000) +} + +describe.skipIf(!benchEnabled)('scheduler drain ceiling', () => { + beforeEach(() => { + vi.stubGlobal('window', globalThis) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('measures foreground (visible pane flood) and background ceilings', async () => { + const foreground = await measure({ foreground: true }) + const background = await measure({ foreground: false }) + // eslint-disable-next-line no-console -- bench harness output + console.log( + `\n[scheduler-ceiling] foreground flood: ${foreground.toFixed(1)} MB/s, background: ${background.toFixed(1)} MB/s (simulated time, instant parse)` + ) + }) +}) + +// Real-timer smoke: the MessageChannel drain path must actually drain a +// high-priority backlog without any timer advancing (the clamp-dodge works). +import { setUseMessageChannelDrainForTesting } from './pane-terminal-output-scheduler' + +describe('message-channel drain path', () => { + it('drains high-priority output with real timers and no timer advance', async () => { + vi.useRealTimers() + setUseMessageChannelDrainForTesting(true) + try { + const writes: string[] = [] + const terminal = { + write: (data: string, cb?: () => void) => { + writes.push(data) + cb?.() + } + } + const { writeTerminalOutput, discardTerminalOutput } = + await import('./pane-terminal-output-scheduler') + for (let i = 0; i < 40; i++) { + writeTerminalOutput(terminal as never, `chunk-${i};`, { foreground: true }) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(writes.join('')).toContain('chunk-39;') + discardTerminalOutput(terminal as never) + } finally { + setUseMessageChannelDrainForTesting(null) + vi.useFakeTimers() + } + }) +}) 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 bca02b5fcc5..f85d9b8c56f 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 @@ -5,6 +5,14 @@ vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + function createTerminal() { const classes = new Set() return { @@ -51,6 +59,7 @@ async function loadScheduler() { describe('pane terminal output scheduler', () => { beforeEach(() => { vi.stubGlobal('window', globalThis) + mocks.recordRendererCrashBreadcrumb.mockClear() }) afterEach(() => { @@ -60,6 +69,168 @@ describe('pane terminal output scheduler', () => { vi.unstubAllGlobals() }) + describe('parse-deferred ACK crediting', () => { + // Why these tests: the credit invariant is "every delivered chunk credits + // exactly once, whether parsed or discarded" — a missed credit permanently + // shrinks main's in-flight window and wedges the PTY (rc.7.perf). + function makeCredit(): { fire: () => void; count: () => number } { + let fired = 0 + return { fire: () => (fired += 1), count: () => fired } + } + + it('credits when a queued chunk finishes parsing', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'queued', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits exactly once when a chunk is split across drain slices', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + // 40 KB > the 16 KB slice size: consumed across multiple drain writes. + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + for (let index = 0; index < 24; index += 1) { + vi.advanceTimersByTime(4) + } + const written = terminal.write.mock.calls.map((call) => String(call[0])).join('') + expect(written).toContain('q'.repeat(40 * 1024)) + expect(credit.count()).toBe(1) + }) + + it('defers split-chunk credit and onParsed until the final slice parses', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const parseCallbacks: (() => void)[] = [] + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + const credit = makeCredit() + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire, + onParsed + }) + vi.advanceTimersByTime(0) + + expect(parseCallbacks).toHaveLength(3) + parseCallbacks[0]() + parseCallbacks[1]() + expect(credit.count()).toBe(0) + expect(onParsed).not.toHaveBeenCalled() + parseCallbacks[2]() + expect(credit.count()).toBe(1) + expect(onParsed).toHaveBeenCalledTimes(1) + }) + + it('credits when the foreground backlog is replaced with the overflow warning', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + configureTerminalOutputBacklogCap(1_000) + const terminal = createTerminal() + // Never complete a write so the queue only grows. + terminal.write.mockImplementation(() => {}) + const credits = [makeCredit(), makeCredit(), makeCredit()] + + for (const credit of credits) { + writeTerminalOutput(terminal, 'x'.repeat(1024 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + } + // The cap replacement discards queued chunks — their deliveries still + // consumed and must credit. + for (const credit of credits) { + expect(credit.count()).toBe(1) + } + }) + + it('credits when queued output is discarded', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'doomed', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + + it('credits an empty write immediately', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + writeTerminalOutput(terminal, '', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(1) + }) + + it('credits the immediate foreground path after its parse callback', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'now', { foreground: true, ackCredit: credit.fire }) + expect(terminal.write).toHaveBeenCalledWith('now', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits submitted but unparsed output when the terminal is discarded', async () => { + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'submitted', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + }) + it('writes foreground output immediately', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -277,7 +448,7 @@ describe('pane terminal output scheduler', () => { expect(terminal._core.refresh).not.toHaveBeenCalled() }) - it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + it('runs parsed callbacks after the final background slice', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -299,14 +470,12 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(50) expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) - expect(parseCallbacks).toHaveLength(2) + expect(parseCallbacks).toHaveLength(1) expect(onParsed).not.toHaveBeenCalled() parseCallbacks[0]?.() expect(onParsed).toHaveBeenCalledTimes(1) - parseCallbacks[1]?.() - expect(onParsed).toHaveBeenCalledTimes(2) }) it('defers throughput foreground output to the shared high-priority drain', async () => { @@ -798,6 +967,31 @@ describe('pane terminal output scheduler', () => { expect(terminals[2].write).toHaveBeenCalledWith('pane-2') }) + it('drains active foreground backlog before older background terminal backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const backgroundA = createTerminal() + const backgroundB = createTerminal() + const active = createTerminal() + + writeTerminalOutput(backgroundA, 'background-a', { foreground: false }) + writeTerminalOutput(backgroundB, 'background-b', { foreground: false }) + writeTerminalOutput(active, 'active', { + foreground: true, + latencySensitive: false + }) + + vi.advanceTimersByTime(0) + + expect(active.write).toHaveBeenCalledWith('active', expect.any(Function)) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundA.write.mock.invocationCallOrder[0] + ) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundB.write.mock.invocationCallOrder[0] + ) + }) + it('rotates terminals with remaining backlog behind untouched queued terminals', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -897,11 +1091,14 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() + // Why 8: promoted backlogs use the parse-clocked high-priority budget + // (HIGH_PRIORITY_MAX_WRITES_PER_DRAIN) so a visible flood drains at the + // parser's pace instead of a fixed 2-write drip. vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(2) + expect(terminal.write).toHaveBeenCalledTimes(8) vi.advanceTimersByTime(4) - expect(terminal.write).toHaveBeenCalledTimes(4) + expect(terminal.write).toHaveBeenCalledTimes(16) }) it('yields high-priority backlog drains when writes spend the frame budget', async () => { @@ -979,6 +1176,95 @@ describe('pane terminal output scheduler', () => { expect(output).not.toContain('x'.repeat(1024)) }) + it('caps a visible pane backlog the drain cannot keep up with and writes a warning', async () => { + // Why: the foreground path was previously uncapped — a flooding visible + // TUI on a starved renderer grew queuedChars without bound (field + // reports of ~1.5 GB renderer RSS before terminals froze). + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: true, latencySensitive: false }) + + vi.advanceTimersByTime(0) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).toContain('after-cap') + expect(output).not.toContain('x'.repeat(1024)) + }) + + it('records a drop breadcrumb with sizes when the cap replaces a backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_output_backlog_dropped', + expect.objectContaining({ + foreground: false, + droppedChars: expect.any(Number), + capChars: 2 * 1024 * 1024 + }) + ) + }) + + it('scales the backlog cap with the scrollback setting', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + // 50k-row scrollback ⇒ 6 MB cap: a 2.5 MB flood that would trip the + // 2 MB floor must survive intact. + configureTerminalOutputBacklogCap(50_000) + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + + let output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).not.toContain('Orca skipped') + expect(output).toContain('x'.repeat(1024)) + + // But the scaled cap still bounds a runaway flood. + terminal.write.mockClear() + for (let i = 0; i < 13; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + }) + + it('caps a held/coalesced foreground backlog as well', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const chunk = 'y'.repeat(512 * 1024) + + // holdForeground engages the synchronized-output hold — the branch a + // flooding TUI in sync mode exercises. + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, holdForeground: true }) + } + + vi.advanceTimersByTime(1_000) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).not.toContain('y'.repeat(1024)) + }) + it('caps hidden backlog chunk count even when each chunk is tiny', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() 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 20a3b8138ca..79ddae6e086 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 @@ -11,6 +11,16 @@ import { captureTerminalWriteScrollIntent, enforceTerminalWriteScrollIntent } from './terminal-scroll-intent' +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + discardInFlightTerminalOutputAckCredits, + registerTerminalOutputAckCredits +} from './pane-terminal-output-ack-credit' +import { + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars +} from '../../../../shared/terminal-scrollback-policy' type TerminalOutputTarget = ForegroundTerminalOutputTarget @@ -22,6 +32,12 @@ type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback + /** Parse-deferred delivery ACK (terminal-pty-ack-gate). The scheduler MUST + * invoke it exactly when the chunk's bytes are parsed by xterm OR discarded + * by any drop path. A missed credit permanently shrinks + * main's in-flight window for this PTY (the callback is fire-once, so + * double invocation is safe; omission is not). */ + ackCredit?: () => void onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -38,6 +54,7 @@ type QueueChunk = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } type QueuedWrite = { @@ -47,6 +64,7 @@ type QueuedWrite = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredits: (() => void)[] } type QueueEntry = { @@ -71,12 +89,26 @@ const BACKGROUND_DRAIN_INTERVAL_MS = 16 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 = 2 +// Why 8: with the parse-clock pacer, high-priority ticks fire only after +// xterm confirms the previous batch parsed, and Chromium clamps chained +// timers to ~4ms — so per-tick volume (8 x 16KB = 128KB ≈ 1.3ms of parse) +// sets the sustained ceiling (~30MB/s) while staying far inside +// DRAIN_TIME_BUDGET_MS. At 2 the ceiling was 8MB/s against a ~100MB/s +// parser (see pane-terminal-output-scheduler-throughput.bench.test.ts). +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 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 +// Why mutable: the cap scales with the user's scrollback setting (see +// terminalOutputBacklogCapChars); the terminal lifecycle configures it when +// settings are applied. The chunk-count cap stays fixed — it bounds queue +// bookkeeping, not retained content. +let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 + +export function configureTerminalOutputBacklogCap(scrollbackRows: unknown): void { + maxQueueChars = terminalOutputBacklogCapChars(scrollbackRows) +} const PARSE_SETTLE_TIMEOUT_MS = 250 const FOREGROUND_COALESCE_DELAY_MS = 1000 const FOREGROUND_HOLD_SAFETY_DELAY_MS = 250 @@ -88,9 +120,15 @@ const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' // Why: CAN aborts a partial escape sequence before resetting style and showing -// the lossy-backlog warning. +// the lossy-backlog warning. Cap-agnostic wording: the byte limit scales with +// the scrollback setting. const BACKGROUND_BACKLOG_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n' + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n' +// Why a separate foreground message: a visible pane hitting the cap means the +// drain could not keep up with a flood (starved renderer) — the output was +// skipped, not merely produced while hidden. +const FOREGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' const queuedByTerminal = new Map() const backlogRecoveryByTerminal = new WeakMap< @@ -99,6 +137,47 @@ const backlogRecoveryByTerminal = new WeakMap< >() let drainTimer: ReturnType | null = null let drainTimerDelayMs: number | null = null +// Why a MessageChannel for zero-delay drains: Chromium clamps nested +// setTimeout(0) to ~4ms, which stacks a dead gap onto every parse-clocked +// drain tick under flood (measured: standing queue ~18ms vs VS Code ~7ms). +// A posted message is still a macrotask — input events and paint are +// serviced between posts — so the cooperative yield survives without the +// clamp. Cancellation is by generation: posts carry the generation they +// were armed with and no-op when it has moved on. +let drainImmediatePending = false +let drainImmediateGeneration = 0 +let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() +let drainChannel: MessageChannel | null = null + +function isVitestEnv(): boolean { + // Why: vitest fake timers cannot advance MessageChannel macrotasks; the + // timer path keeps the existing suites' virtual clock authoritative. + return typeof process !== 'undefined' && process.env?.VITEST === 'true' +} + +function getDrainChannel(): MessageChannel { + if (drainChannel === null) { + drainChannel = new MessageChannel() + drainChannel.port1.onmessage = (event: MessageEvent) => { + if (event.data !== drainImmediateGeneration || !drainImmediatePending) { + return + } + drainImmediatePending = false + drainQueuedOutput() + } + } + return drainChannel +} + +function cancelImmediateDrain(): void { + drainImmediateGeneration++ + drainImmediatePending = false +} + +export function setUseMessageChannelDrainForTesting(value: boolean | null): void { + cancelImmediateDrain() + useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) +} const debugEnabled = e2eConfig.exposeStore // Why the cap is lossy: a hidden/backgrounded Chromium document can throttle @@ -219,6 +298,10 @@ function exposeDebugApi(): void { } function scheduleDrain(delayMs: number): void { + if (drainImmediatePending) { + // An immediate drain is already armed — nothing can beat zero delay. + return + } if (drainTimer !== null) { if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { return @@ -233,6 +316,11 @@ function scheduleDrain(delayMs: number): void { if (debugEnabled) { debugState.scheduledDrainCount++ } + if (delayMs === 0 && useMessageChannelDrain) { + drainImmediatePending = true + getDrainChannel().port2.postMessage(drainImmediateGeneration) + return + } drainTimer = setTimeout(drainQueuedOutput, delayMs) drainTimerDelayMs = delayMs } @@ -485,6 +573,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let followupForegroundRefresh = false let stripTransientCursorShows = false const parsedCallbacks: TerminalOutputParsedCallback[] = [] + const ackCredits: (() => void)[] = [] while (remaining > 0 && entry.chunkIndex < entry.chunks.length) { const chunk = entry.chunks[entry.chunkIndex] @@ -503,13 +592,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) } + if (chunk.ackCredit) { + ackCredits.push(chunk.ackCredit) + } continue } data += chunk.data.slice(0, remaining) - if (chunk.onParsed) { - parsedCallbacks.push(chunk.onParsed) - } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -537,7 +626,8 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { callback() } } - : undefined + : undefined, + ackCredits } : null } @@ -566,6 +656,7 @@ function enqueueChunk( followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } ): void { entry.chunks.push({ @@ -574,18 +665,49 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onParsed: options?.onParsed + onParsed: options?.onParsed, + ackCredit: options?.ackCredit }) entry.queuedChars += data.length recordQueueDebugPressure() } -function replaceBacklogWithWarning(entry: QueueEntry): void { +// Fires the delivery ACK credits of every not-yet-consumed queued chunk. +// Every discard path MUST call this before clearing/replacing the queue — +// a dropped chunk still counts as consumed for main's in-flight window, or +// the window shrinks permanently and the PTY wedges behind lost credit. +function fireQueuedAckCredits(entry: QueueEntry): void { + for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { + entry.chunks[index].ackCredit?.() + } +} + +function queueCapExceeded(entry: QueueEntry): boolean { + return ( + entry.queuedChars > maxQueueChars || + entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS + ) +} + +function replaceBacklogWithWarning( + entry: QueueEntry, + warning: string = BACKGROUND_BACKLOG_WARNING +): void { const shouldNotify = !entry.backgroundBacklogDropped + if (shouldNotify) { + // Why: field visibility for cap tuning — how often drops happen and at + // what size decides whether the cap is too small (issue #2836 / #7017). + recordRendererCrashBreadcrumb('terminal_output_backlog_dropped', { + foreground: warning === FOREGROUND_BACKLOG_WARNING, + droppedChars: entry.queuedChars, + capChars: maxQueueChars + }) + } clearForegroundHoldSafety(entry) + fireQueuedAckCredits(entry) entry.chunks = [ { - data: BACKGROUND_BACKLOG_WARNING, + data: warning, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, @@ -593,7 +715,7 @@ function replaceBacklogWithWarning(entry: QueueEntry): void { } ] entry.chunkIndex = 0 - entry.queuedChars = BACKGROUND_BACKLOG_WARNING.length + entry.queuedChars = warning.length entry.backgroundBacklogDropped = true entry.highPriority = true entry.foregroundHold = false @@ -637,26 +759,34 @@ function writeBackgroundTerminalChunk( data: string, onParsed?: TerminalOutputParsedCallback ): void { + // Why guarded: these callbacks run inside xterm's WriteBuffer loop, where an + // escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). + const runOnParsed = onParsed + ? (): void => runGuardedWriteCompletionStep('background-on-parsed', onParsed) + : undefined const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - if (!onParsed || terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - onParsed?.() + runOnParsed?.() return } - terminal.write(data, onParsed) + terminal.write(data, runOnParsed) return } + const runScrollIntentThenParsed = (): void => { + runGuardedWriteCompletionStep('background-scroll-intent', () => + enforceTerminalWriteScrollIntent(terminal, scrollIntent) + ) + runOnParsed?.() + } if (terminal.write.length < 2) { terminal.write(data) - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() + runScrollIntentThenParsed() return } - terminal.write(data, () => { - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() - }) + terminal.write(data, runScrollIntentThenParsed) } function writeForegroundTerminalChunkWithIntent( @@ -682,6 +812,25 @@ function writeForegroundTerminalChunkWithIntent( } function takeNextDrainableEntry(): QueueEntry | null { + let largeBacklogEntry: QueueEntry | null = null + for (const entry of queuedByTerminal.values()) { + if (!isEntryDrainable(entry)) { + continue + } + // Why: active/foreground output should be chosen first, not just widen the + // drain budget while older background terminals keep their insertion order. + if (entry.highPriority) { + queuedByTerminal.delete(entry.terminal) + return entry + } + if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) { + largeBacklogEntry = entry + } + } + if (largeBacklogEntry) { + queuedByTerminal.delete(largeBacklogEntry.terminal) + return largeBacklogEntry + } for (const entry of queuedByTerminal.values()) { if (!isEntryDrainable(entry)) { continue @@ -692,11 +841,50 @@ function takeNextDrainableEntry(): QueueEntry | null { return null } +// Why: the parse-completion pacer re-arms a zero-delay drain as soon as xterm +// reports the previous high-priority batch parsed. Without it, cadence is a +// fixed 4/16ms nap per <=32KB batch — a ~2-8 MB/s drip against xterm's +// ~100 MB/s parse rate (measured: scheduler-throughput bench + baseline-jul02). +// Only high-priority (visible-pane) backlogs are pacer-clocked; background +// panes keep the fixed cadence that protects the focused terminal. +function makeParseClockPacer(): () => void { + return () => { + try { + if (queuedByTerminal.size > 0 && hasHighPriorityBacklog()) { + scheduleDrain(0) + } + } catch { + // Why: runs inside xterm's write-callback chain; a throw here would + // wedge the terminal (see xterm-write-callback-guard.ts). + } + } +} + +function composeParsedCallback( + onParsed: TerminalOutputParsedCallback | undefined, + ackCreditsParsed: (() => void) | undefined, + pacer: (() => void) | undefined +): TerminalOutputParsedCallback | undefined { + if (!onParsed && !ackCreditsParsed && !pacer) { + return undefined + } + return () => { + try { + onParsed?.() + } finally { + ackCreditsParsed?.() + pacer?.() + } + } +} + function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) if (!queuedWrite) { return null } + const pacer = entry.highPriority ? makeParseClockPacer() : undefined + const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -708,16 +896,22 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + entry.terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing // the scheduler for other panes still draining. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -781,8 +975,17 @@ function drainQueuedOutput(): void { } recordQueueDebugPressure() if (queuedByTerminal.size > 0 && hasDrainableBacklog()) { + // Why 0 on the channel path: the 4ms high-priority interval existed to + // yield between ticks, but a posted message already yields — Chromium + // services input and paint between macrotasks. The explicit sleep only + // deepened the standing queue (~4ms per 128KB tick). Timer path keeps + // the interval so fake-timer tests retain stepwise drain semantics. scheduleDrain( - hasHighPriorityBacklog() ? HIGH_PRIORITY_DRAIN_INTERVAL_MS : BACKGROUND_DRAIN_INTERVAL_MS + hasHighPriorityBacklog() + ? useMessageChannelDrain + ? 0 + : HIGH_PRIORITY_DRAIN_INTERVAL_MS + : BACKGROUND_DRAIN_INTERVAL_MS ) } } @@ -794,6 +997,9 @@ export function writeTerminalOutput( ): void { exposeDebugApi() if (!data) { + // Why: an empty write still consumed its delivery — credit or main's + // in-flight window leaks. + options.ackCredit?.() return } @@ -810,12 +1016,20 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + // Why: a visible pane's queue was previously uncapped — a flood the + // drain can't keep up with ballooned renderer memory without bound. + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + scheduleDrain(0) + return + } if (options.holdForeground) { // Why: synchronized-output start/body chunks contain transient cursor // moves. Holding them prevents Chromium from rasterizing those states. @@ -877,12 +1091,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(entry)) { + replaceBacklogWithWarning(entry, FOREGROUND_BACKLOG_WARNING) + } // Why: returning from a hidden window can have megabytes queued. Keep // byte order, but drain it asynchronously so the first foreground frame // is not pinned behind the entire backlog. @@ -904,12 +1122,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + } // Why: visible command floods are throughput work, not keystroke echo. // Queue them behind a zero-delay drain so one IPC callback cannot pin // the renderer in xterm.write while input and paint are waiting. @@ -920,16 +1142,27 @@ export function writeTerminalOutput( if (debugEnabled) { debugState.foregroundWriteCount++ } - options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + const ackCreditsParsed = registerTerminalOutputAckCredits( terminal, - options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, - { - forceViewportRefresh: options.forceForegroundRefresh === true, - followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onParsed - } + options.ackCredit ? [options.ackCredit] : [] ) + try { + options.beforeWrite?.(data) + writeForegroundTerminalChunkWithIntent( + terminal, + options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, + { + forceViewportRefresh: options.forceForegroundRefresh === true, + followupViewportRefresh: options.followupForegroundRefresh === true, + onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined) + } + ) + } catch (error) { + // beforeWrite can throw before xterm owns the callback; consume the + // delivery here. xterm write throws are caught by the foreground writer. + ackCreditsParsed?.() + throw error + } return } @@ -943,12 +1176,10 @@ export function writeTerminalOutput( entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } enqueueChunk(entry, data, { - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) - if ( - entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || - entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS - ) { + if (queueCapExceeded(entry)) { replaceBacklogWithWarning(entry) } if (debugEnabled) { @@ -977,6 +1208,7 @@ export function flushTerminalOutput( return } if (entry.backgroundBacklogDropped && requestRegisteredTerminalBacklogRecovery(terminal)) { + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -994,6 +1226,7 @@ export function flushTerminalOutput( if (debugEnabled) { debugState.flushWriteCount++ } + const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -1005,16 +1238,23 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing - // the scheduler for other panes still draining. + // the scheduler for other panes still draining. Consumed + abandoned + // chunks both credit their deliveries. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) recordQueueDebugPressure() @@ -1089,6 +1329,13 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro export function discardTerminalOutput(terminal: TerminalOutputTarget): void { exposeDebugApi() + const entry = queuedByTerminal.get(terminal) + if (entry) { + // Why: discarded queued chunks still consumed their deliveries — credit + // them or main's in-flight window leaks (see fireQueuedAckCredits). + fireQueuedAckCredits(entry) + } + discardInFlightTerminalOutputAckCredits(terminal) queuedByTerminal.delete(terminal) discardForegroundRenderSettle(terminal) recordQueueDebugPressure() 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 e0ca805801e..7ae295b4a9d 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -170,6 +171,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.id, '— falling back to DOM renderer' ) + // Why: a lost context is the decisive signal for a post-wake garble + // report — it means the glyph atlas was wiped (needs a full reset), not + // just a missed repaint. Silent breadcrumb; the console.warn stays. + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: pane.id }) // Why: Chromium starts reclaiming terminal contexts under pressure. // Recreating WebGL for this pane can loop context loss and leave xterm // visually blank, so keep the pane on the DOM renderer until the next diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index bfd67604707..6060ec3b3ee 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { buildWindowsPtyCompatibilityOptions, isLocalNativeWindowsConpty, - isLocalNativeWindowsPty + isLocalNativeWindowsPty, + resolveWindowsShellOverride } from './windows-pty-compatibility' function writeTerminal(terminal: Terminal, data: string): Promise { @@ -164,6 +165,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => { ).toEqual({}) }) + it('classifies a global-WSL default shell as non-native, matching main', () => { + // Why: main folds the global terminalWindowsShell into its spawn + // classification (isNativeWindowsLocalPtySpawn). Without the fold the + // renderer would call a tab with no override native-ConPTY while main + // never marks it. + const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe') + }) + ).toBe(false) + // A tab-level override beats the global setting, both directions. + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe') + }) + ).toBe(true) + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe') + }) + ).toBe(false) + }) + it('exposes the same local native Windows predicate for related renderer workarounds', () => { expect( isLocalNativeWindowsPty({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 0f35968c21d..549defbb3a5 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -62,6 +62,17 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a + * tab-level shell override wins, else the global Windows shell setting + * applies — so renderer and main classify a global-WSL default identically + * (the main-side twin is isNativeWindowsLocalPtySpawn). */ +export function resolveWindowsShellOverride( + tabShellOverride: string | null | undefined, + globalWindowsShell: string | null | undefined +): string | undefined { + return tabShellOverride ?? globalWindowsShell ?? undefined +} + /** * Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH * connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts new file mode 100644 index 00000000000..52285bae7bd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetWriteCompletionReportsForTests, + runGuardedWriteCompletionStep +} from './xterm-write-callback-guard' +import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetWriteCompletionReportsForTests() +}) + +describe('runGuardedWriteCompletionStep', () => { + it('contains a synchronous throw and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => + runGuardedWriteCompletionStep('test-step', () => { + throw new RangeError('synthetic settle failure') + }) + ).not.toThrow() + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ + context: 'test-step', + errorName: 'RangeError', + errorMessage: 'synthetic settle failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per context so a throw-per-write loop cannot spam breadcrumbs', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + for (let i = 0; i < 20; i++) { + runGuardedWriteCompletionStep('spammy-step', () => { + throw new Error('always fails') + }) + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('runs non-throwing steps transparently', () => { + const step = vi.fn() + runGuardedWriteCompletionStep('ok-step', step) + expect(step).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) +}) + +describe('writeForegroundTerminalChunk completion guarding', () => { + it('still releases onParsed when the settle step throws (replay-guard latch protection)', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const pendingCallbacks: (() => void)[] = [] + // Why a getter that throws only after the write is dispatched: it + // models renderer/buffer state failing between parse start and the + // post-parse viewport settle (refreshVisibleRowsNow self-catches, so + // the viewport comparison is the escaping surface). + let bufferAccessPoisoned = false + const realBuffer = { active: { cursorY: 0, baseY: 0, viewportY: 0 } } + const terminal = { + rows: 24, + get buffer() { + if (bufferAccessPoisoned) { + throw new Error('synthetic buffer access failure') + } + return realBuffer + }, + write: (_data: string, cb?: () => void) => { + if (cb) { + pendingCallbacks.push(cb) + } + } + } + const onParsed = vi.fn() + + writeForegroundTerminalChunk(terminal, 'restored bytes', { + forceViewportRefresh: true, + onParsed + }) + bufferAccessPoisoned = true + // Simulate xterm completing the parse: the completion callback must not + // let the settle throw escape into the WriteBuffer, and onParsed (the + // replay-guard release) must still run. + expect(() => pendingCallbacks.forEach((cb) => cb())).not.toThrow() + expect(onParsed).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ context: 'foreground-render-settle' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts new file mode 100644 index 00000000000..0eb9227737c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts @@ -0,0 +1,40 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's WriteBuffer._innerWrite invokes write-completion callbacks with +// no try/catch; a synchronous throw skips the loop's tail re-schedule, and +// write() only re-arms processing when the buffer is EMPTY — which a stalled +// buffer never is again. One escaping throw therefore permanently freezes the +// pane: output stops rendering and a pending replay guard never releases, so +// the pane silently eats every keystroke while the shell stays alive +// (Discord #performance / issue #2836). Verified against the vendored xterm +// 6.1.0-beta.287 in xterm-write-buffer-stall.repro.test.ts. +const MAX_REPORTS_PER_CONTEXT = 5 +const reportCountsByContext = new Map() + +/** + * Run one step of a write-completion callback so a synchronous throw cannot + * escape into xterm's WriteBuffer. Steps are guarded individually so an + * earlier step's failure (e.g. a WebGL refresh during viewport settle) cannot + * starve a later step (e.g. the replay-guard release). + */ +export function runGuardedWriteCompletionStep(context: string, step: () => void): void { + try { + step() + } catch (error: unknown) { + const reported = reportCountsByContext.get(context) ?? 0 + if (reported >= MAX_REPORTS_PER_CONTEXT) { + return + } + reportCountsByContext.set(context, reported + 1) + console.error(`[terminal] write-completion step "${context}" threw`, error) + recordRendererCrashBreadcrumb('terminal_write_completion_error', { + context, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } +} + +export function _resetWriteCompletionReportsForTests(): void { + reportCountsByContext.clear() +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index daebefb6e73..e5ada3e6ca6 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -181,7 +181,7 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa']) expect(server.droppedFrames).toBe(1) // Instead, a fresh authoritative snapshot recovers the terminal. - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('passes contiguous output straight through without resyncing', async () => { @@ -208,7 +208,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual(['é']) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('defers recovery until an in-flight manual snapshot finishes', async () => { @@ -231,6 +231,6 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) expect(server.snapshotRequests).toHaveLength(2) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5f0b751a91f..e854a5e7efc 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -9,6 +9,7 @@ import { encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' +import { e2eConfig } from '@/lib/e2e-config' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -70,10 +71,12 @@ type RemoteRuntimeMultiplexedTerminalState = { streamId: number terminal: string callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + acknowledgeOutput: boolean + heldAckBytes: number snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean - snapshotTarget: 'initial' | 'request' + snapshotTarget: 'initial' | 'request' | 'recovery' snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null @@ -123,6 +126,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' +type E2eRemoteTerminalMultiplexAckGateSnapshot = { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number +} + +type E2eRemoteTerminalMultiplexAckGateApi = { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot +} + +type E2eRemoteTerminalMultiplexAckGateWindow = Window & { + __remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi +} + +const e2eHeldRemoteAckTerminals = new Set() +let e2eReleasedRemoteAckChars = 0 + +function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { + return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal) +} + +function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { + let heldStreamCount = 0 + let heldAckChars = 0 + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (stream.heldAckBytes > 0) { + heldStreamCount += 1 + heldAckChars += stream.heldAckBytes + } + } + } + return { + heldTerminalCount: e2eHeldRemoteAckTerminals.size, + heldStreamCount, + heldAckChars, + releasedAckChars: e2eReleasedRemoteAckChars + } +} + +function releaseE2eRemoteTerminalAcks(): void { + for (const multiplexer of multiplexers.values()) { + e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e() + } + e2eHeldRemoteAckTerminals.clear() +} + +function exposeE2eRemoteTerminalMultiplexAckGate(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eRemoteTerminalMultiplexAckGateWindow + target.__remoteTerminalMultiplexAckGate ??= { + hold: (terminals) => { + releaseE2eRemoteTerminalAcks() + for (const terminal of terminals) { + e2eHeldRemoteAckTerminals.add(terminal) + } + }, + release: releaseE2eRemoteTerminalAcks, + snapshot: getE2eRemoteAckSnapshot + } +} + class RemoteRuntimeTerminalMultiplexer { private readonly streams = new Map() private subscription: RuntimeEnvironmentSubscriptionHandle | null = null @@ -152,6 +222,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, callbacks: args.callbacks, + acknowledgeOutput: args.client.type === 'desktop', + heldAckBytes: 0, snapshotChunks: [], snapshotBytes: 0, snapshotOverflowed: false, @@ -198,7 +270,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, client: args.client, - viewport: args.viewport + viewport: args.viewport, + capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined }) ) if (!sent) { @@ -346,21 +419,31 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - const rawLength = data.length - // Why: a resync snapshot is authoritative; drop live output that arrives - // while it is in flight so the corrupt post-gap tail is never rendered. - if (stream.resyncInFlight) { - return + try { + const rawLength = data.length + // Why: a resync snapshot is authoritative; discard live output while + // it is in flight, but still return transport credit in finally. + if (stream.resyncInFlight) { + return + } + const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined + if (this.detectOutputGap(stream, seq, rawLength)) { + this.requestResyncSnapshot(stream) + return + } + if (typeof seq === 'number') { + stream.expectedSeq = seq + } + stream.callbacks.onData(data, { seq, rawLength }) + } finally { + if (stream.acknowledgeOutput) { + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } + } } - const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined - if (this.detectOutputGap(stream, seq, rawLength)) { - this.requestResyncSnapshot(stream) - return - } - if (typeof seq === 'number') { - stream.expectedSeq = seq - } - stream.callbacks.onData(data, { seq, rawLength }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { @@ -371,7 +454,9 @@ class RemoteRuntimeTerminalMultiplexer { typeof requestId === 'number' || (stream.initialSnapshotReceived && stream.pendingSnapshotRequest) ? 'request' - : 'initial' + : stream.initialSnapshotReceived + ? 'recovery' + : 'initial' return } if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { @@ -417,6 +502,14 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi }) + } else if (target === 'recovery') { + // Why: a server-pushed recovery snapshot replaces terminal state + // mid-session; clear the screen and scrollback before applying it. + // An empty snapshot is still applied so stale dropped output does + // not linger on a terminal the model says is blank. + stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi + }) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) @@ -562,6 +655,33 @@ class RemoteRuntimeTerminalMultiplexer { return id } + private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean { + return this.sendFrame( + stream.streamId, + TerminalStreamOpcode.Ack, + encodeTerminalStreamJson({ bytes }) + ) + } + + getStreamsForE2e(): Iterable { + return this.streams.values() + } + + releaseHeldAcksForE2e(): number { + let released = 0 + for (const stream of this.streams.values()) { + if (stream.heldAckBytes <= 0) { + continue + } + const bytes = stream.heldAckBytes + stream.heldAckBytes = 0 + if (this.acknowledgeOutput(stream, bytes)) { + released += bytes + } + } + return released + } + private sendFrame( streamId: number, opcode: TerminalStreamOpcode, @@ -644,6 +764,7 @@ function releaseRemoteRuntimeTerminalMultiplexer( export function getRemoteRuntimeTerminalMultiplexer( environmentId: string ): RemoteRuntimeTerminalMultiplexer { + exposeE2eRemoteTerminalMultiplexAckGate() let multiplexer = multiplexers.get(environmentId) if (!multiplexer) { multiplexer = new RemoteRuntimeTerminalMultiplexer( @@ -661,6 +782,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() + e2eHeldRemoteAckTerminals.clear() + e2eReleasedRemoteAckChars = 0 } function concatBytes(chunks: Uint8Array[]): Uint8Array { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..79166f666d2 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -4,6 +4,7 @@ import { decodeTerminalStreamFrame, decodeTerminalStreamJson, encodeTerminalStreamFrame, + encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { @@ -98,8 +99,12 @@ describe('remote runtime terminal data subscriptions', () => { const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) const subscribePayload = - subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + subscribeFrame && + decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>( + subscribeFrame.payload + ) expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 }) callbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -111,6 +116,12 @@ describe('remote runtime terminal data subscriptions', () => { ) expect(watcher).toHaveBeenCalledWith('live') + const ackFrame = sendBinary.mock.calls + .slice(1) + .map((call) => decodeTerminalStreamFrame(call[0])) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(ackFrame?.streamId).toBe(subscribePayload!.streamId) + expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 }) expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) dispose() expect(unsubscribe).toHaveBeenCalled() @@ -189,3 +200,228 @@ describe('remote runtime terminal data subscriptions', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('remote runtime terminal multiplex ACK gate', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + e2e: { + getConfig: () => ({ exposeStore: true }) + }, + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('holds and releases ACKs for selected remote terminal streams only', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate') + const heldTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-held', + client: { id: 'desktop-held', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + const liveTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-live', + client: { id: 'desktop-live', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2)) + const heldStreamId = heldTerminal.streamId + const liveStreamId = liveTerminal.streamId + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number + } + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate).toBeDefined() + gate?.hold(['terminal-held']) + sendBinary.mockClear() + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: heldStreamId, + seq: 1, + payload: encodeTerminalStreamText('held-output') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: liveStreamId, + seq: 2, + payload: encodeTerminalStreamText('live-output') + }) + ) + + const immediateAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(immediateAckFrames).toHaveLength(1) + expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 1, + heldStreamCount: 1, + heldAckChars: 'held-output'.length + }) + + gate?.release() + const allAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId) + expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({ + bytes: 'held-output'.length + }) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 0, + heldStreamCount: 0, + heldAckChars: 0, + releasedAckChars: 'held-output'.length + }) + + heldTerminal.close() + liveTerminal.close() + }) + + it('applies mid-session recovery snapshots without re-subscribing', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery') + const onSnapshot = vi.fn() + const onSubscribed = vi.fn() + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-recovery', + client: { id: 'desktop-recovery', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot, + onSubscribed + } + }) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const streamId = stream.streamId + + const injectSnapshot = (info: Record, text: string): void => { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson(info) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(text) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array(0) + }) + ) + } + + injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state') + expect(onSnapshot).toHaveBeenCalledWith('initial state', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + 'recovered state' + ) + // Why: an unsolicited recovery snapshot replaces terminal state, so it + // clears screen and scrollback first and must not replay the subscribe + // lifecycle. + expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + // Why: an empty recovery snapshot means the model terminal is blank, so + // the client must still clear stale dropped output. + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + '' + ) + expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + stream.close() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index b773a0d7721..bf005d398e7 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -214,6 +214,52 @@ describe('captureAllSleepingAgentSessions', () => { }) }) + it('skips rewriting an unchanged resume record on repeated capture', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store.getState().captureAllSleepingAgentSessions() + const first = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(first).toMatchObject({ origin: 'quit' }) + + // Why: the periodic resume-record capture re-runs this action every + // minute; an unchanged agent must not dirty the store (and the debounced + // session write) with a record differing only by capturedAt. + store.getState().captureAllSleepingAgentSessions() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(first) + + // A real status change must still refresh the record. + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'waiting', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store.getState().captureAllSleepingAgentSessions() + const refreshed = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(refreshed).not.toBe(first) + expect(refreshed).toMatchObject({ state: 'waiting', origin: 'quit' }) + }) + it('preserves hydrated launch config during live recapture without a registry entry', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 182d113411d..10415550216 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -603,6 +603,34 @@ export function collectHibernatedCompletionEvidenceForWorktree( return retained } +// Why: the periodic resume-record capture re-runs on an interval; comparing +// everything except capturedAt lets an unchanged agent skip the store write +// entirely, so idle ticks never dirty the session persistence pipeline. +function sleepingRecordsEquivalentIgnoringCaptureTime( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.paneKey === next.paneKey && + existing.tabId === next.tabId && + existing.worktreeId === next.worktreeId && + existing.agent === next.agent && + existing.providerSession.key === next.providerSession.key && + existing.providerSession.id === next.providerSession.id && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + existing.origin === next.origin && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + function recoveryRecordMatches( existing: SleepingAgentSessionRecord | undefined, next: SleepingAgentSessionRecord @@ -2203,7 +2231,10 @@ export const createAgentStatusSlice: StateCreator { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' }, worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'runtime:env-1' })] }, tabsByWorktree: {}, ptyIdsByTabId: {}, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 211dded1173..77d93f75d19 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -57,6 +57,10 @@ import { restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +// Why: import the store-free registry, not terminal-parked-tab-watchers — +// that module imports @/store, and a slice importing it would re-enter store +// creation before this slice finishes evaluating. +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId @@ -2281,6 +2285,11 @@ export const createTerminalSlice: StateCreator // Removing the data handlers first ensures the final flush is a no-op. if (expectedRuntimePtyIds.length === 0) { unregisterPtyDataHandlers(shutdownPtyIds) + // Why: parked-tab byte watchers observe the same flush through dispatcher + // sidecars, which the call above does not touch — dispose them now or a + // just-slept/deleted worktree still gets unread marks and delayed + // bell/completion OS notifications from its teardown bytes. + disposeParkedTerminalWatchersForPtyIds(shutdownPtyIds) } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 1f2ce461ba2..e664e42f8b6 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -20,7 +20,7 @@ import type { WorktreeMeta, WorkspaceKey } from '../../../../shared/types' -import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { PendingWorktreeCreation, WorktreeCreationPhase diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4bd602c7634..13fa9913334 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -51,6 +51,7 @@ import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../shared/orca-profiles' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' import { @@ -571,6 +572,9 @@ function createWebPreloadApi(): Partial { }, settings: { get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous in the web client, + // so the pre-hydration kill-switch read works the same as desktop. + getSync: () => getStoredSettings(), set: async (updates) => { if (updates.activeRuntimeEnvironmentId === null) { disconnectActiveRuntimeEnvironment() @@ -2707,9 +2711,21 @@ function createPtyApi(): NonNullable['pty']> { kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why healthy stub: web terminals ride the remote-runtime transport, not + // main's delivery gate — a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, rendererDispatcherReady: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), @@ -2717,6 +2733,10 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve([]), hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs never transit local main, so the web client has + // no side-effect facts source; renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ pendingPtyCount: 0, @@ -2732,6 +2752,14 @@ function createPtyApi(): NonNullable['pty']> { peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -2740,6 +2768,7 @@ function createPtyApi(): NonNullable['pty']> { resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, onExit: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 90e72a93d90..dbb13895a1a 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,34 +1,35 @@ /** - * Compatibility barrel for terminal-title agent detection — used by the main - * process (stats collection), the renderer (activity indicators, unread - * badges), and shared siblings. + * Compatibility barrel for shared terminal agent-title detection. * - * The implementation was split into domain modules in Phase 3 of the title - * evidence work: identity/label detection → `terminal-title-agent-type`, status - * classification → `terminal-title-status`, and display normalization → - * `terminal-title-display`. This barrel is kept so the existing main/mobile/ - * renderer import paths that reference `agent-detection` stay stable. + * Why shared: main and renderer both consume OSC titles for facts, stats, and + * UI state. Keep existing imports stable while the implementation stays split + * into focused modules that satisfy max-lines. (main's #7612 split into + * `terminal-title-*` modules coexists — those files stay on disk for their + * direct `resolveTerminalTitleAgentType`/`synthetic-agent-title` consumers.) */ -export { titleHasAgentName } from './agent-name-token-match' +export type { AgentStatus } from './agent-title-core' +export { + isClaudeManagementTitle, + isCursorNativeAgentTitle, + isGeminiTerminalTitle, + isPiTerminalTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './agent-title-core' +export { getAgentLabel, isClaudeAgent } from './agent-title-identity' +export { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + normalizeTerminalTitle +} from './agent-title-status' + +// Re-export so existing `agent-detection` importers keep working. +export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' -export { - getAgentLabel, - isClaudeAgent, - isClaudeManagementTitle, - isGeminiTerminalTitle, - isPiTerminalTitle -} from './terminal-title-agent-type' -export type { AgentStatus } from './terminal-title-status' -export { - createAgentStatusTracker, - detectAgentStatusFromTitle, - STRONG_IDLE_KEYWORDS_RE, - STRONG_WORKING_KEYWORDS_RE -} from './terminal-title-status' -export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display' diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts new file mode 100644 index 00000000000..1bafae3cbd4 --- /dev/null +++ b/src/shared/agent-title-core.ts @@ -0,0 +1,110 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' + +export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } + +export type AgentStatus = 'working' | 'permission' | 'idle' + +export const CLAUDE_IDLE = '\u2733' // ✳ +const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` +export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( + String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`, + 'i' +) + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ +export const GEMINI_IDLE = '\u25c7' // ◇ +export const GEMINI_PERMISSION = '\u270b' // ✋ + +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as +// "~/codex/ready"; the left side also blocks path separators for Windows/Unix. +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsLegacyAgentName(title: string): boolean { + return titleHasAnyLegacyAgentName(title) +} + +export function containsAgentName(title: string): boolean { + return ( + containsLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +export function containsAny(title: string, words: readonly string[]): boolean { + const lower = title.toLowerCase() + return words.some((word) => lower.includes(word)) +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts new file mode 100644 index 00000000000..89dbded12f8 --- /dev/null +++ b/src/shared/agent-title-identity.ts @@ -0,0 +1,111 @@ +import { + AGY_AGENT_NAME_RE, + CLAUDE_IDLE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + containsBrailleSpinner, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + titleHasAgentName +} from './agent-title-core' +import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude title prefixes are stronger than task text, which can mention + // other agents without changing the owning CLI. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + return !lower.includes('cursor') && !lower.includes('openclaude') + } + + const trimmedTitle = title.trimStart() + return ( + trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') + ) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude task titles can mention another CLI; the prefix is the identity + // signal, not arbitrary task text. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + if (isPiAgentTitle(title)) { + return 'Pi' + } + + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: match explicit names before Claude's generic braille heuristic. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts new file mode 100644 index 00000000000..7c589e9935e --- /dev/null +++ b/src/shared/agent-title-status.ts @@ -0,0 +1,203 @@ +import { + AGY_AGENT_NAME_RE, + BRAILLE_SPINNER_RE, + CLAUDE_IDLE, + CURSOR_NATIVE_TITLE_LOWER, + DROID_AGENT_NAME_RE, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + HERMES_AGENT_NAME_RE, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE_GLOBAL, + containsAgentName, + containsAny, + containsBrailleSpinner, + containsLegacyAgentName, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + isPiTerminalTitle +} from './agent-title-core' +import type { AgentStatus } from './agent-title-core' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' + +/** + * Strip working-status indicators so stale exit titles stop reporting working. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + return cleaned || title +} + +/** + * Tracks agent status transitions from terminal title changes. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void, + initialTitle?: string +): { + handleTitle: (title: string) => void + seedTitle: (title: string) => void + reset: () => void +} { + // Why: trackers restored mid-session need a last-known status without firing + // callbacks, or a hidden working agent can miss its later idle transition. + let lastStatus: AgentStatus | null = + initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: reverting to a plain shell prompt after idle/permission means the + // agent exited; while working it can just be a transient internal title. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + seedTitle(title: string): void { + lastStatus = detectAgentStatusFromTitle(title) + }, + reset(): void { + lastStatus = null + } + } +} + +/** + * Normalize high-churn agent titles into stable display labels before storage. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi animates every 80ms; collapse frames while preserving status. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + // Why: Grok Build interpolates a rotating status/tool phrase between the + // spinner and its name, so its working frames change the title many times per + // turn. Collapse them to one stable label; idle/session titles carry no + // spinner and pass through, so the meaningful final title still shows (#7863). + if (isGrokRotatingWorkingTitle(title)) { + return '\u280b Grok' + } + + return title +} + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title || isClaudeManagementTitle(title)) { + return null + } + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + if (isPiTerminalTitle(title)) { + return 'idle' + } + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = containsLegacyAgentName(title) + if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) { + return null + } + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: boundary-aware regexes avoid cwd/path and substring false positives. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Droid hook events are authoritative; native name-only titles should + // not turn a still-sleeping execute tool into completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' +} diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts new file mode 100644 index 00000000000..afffcc0a411 --- /dev/null +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -0,0 +1,279 @@ +// Seeded ANSI byte-stream generator for the terminal garble differential +// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent +// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel +// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ +// emoji, wrapped long lines, alt-screen sessions, and scroll regions. +// +// Deliberately excluded ops (they would fuzz behavior the snapshot/restore +// contract does not promise to preserve, so any diff would be noise, not a +// garble bug): +// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and +// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only +// covers alt-screen/bracketed-paste/app-cursor/mouse modes. +// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract +// (headless-emulator.ts onQueryReply gating); replies are a separate +// authority problem with its own pinned tests (session.test.ts). + +/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export type AgentTuiStreamDims = { cols: number; rows: number } + +export type AgentTuiStreamProfile = { + /** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build + * rehydrate parity; the renderer-side fuzz cannot import that main-only + * module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */ + includeMouseModes: boolean + /** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but + * SerializeAddon never re-emits OSC 8 — production restores link ranges + * out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so + * byte-replay fidelity legitimately drops that underline. The suites pin + * the metadata compensation in a targeted test instead of fuzzing it. */ + includeOscHyperlinks: boolean + opCount: number +} + +function int(rng: () => number, min: number, max: number): number { + return min + Math.floor(rng() * (max - min + 1)) +} + +function pick(rng: () => number, values: readonly T[]): T { + return values[Math.floor(rng() * values.length)]! +} + +const WORDS = [ + 'reading', + 'src/main/daemon/session.ts', + 'tokens 12.4k', + 'esc to interrupt', + 'Thinking…', + 'bash: pnpm typecheck', + '+142 -37', + 'PASS terminal.test.ts', + 'waiting for approval', + 'diff --git a/pty.ts' +] as const + +const WIDE_RUNS = [ + '你好世界', + '터미널 상태 확인', + '進捗を表示中', + '🟢 working', + '🤖 codex', + '✅ done ✨', + // ZWJ emoji join — the exact width divergence the Orca unicode provider + // exists for (shared/terminal-unicode-provider.ts). + '👨‍👩‍👧‍👦 team', + '🇰🇷 locale' +] as const + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const + +function sgr(rng: () => number): string { + const roll = rng() + if (roll < 0.2) { + return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m` + } + if (roll < 0.8) { + return `\x1b[38;5;${int(rng, 0, 255)}m` + } + return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m` +} + +function textRun(rng: () => number): string { + const parts: string[] = [] + const count = int(rng, 1, 3) + for (let i = 0; i < count; i++) { + parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS)) + } + return parts.join(' ') +} + +function styledLine(rng: () => number): string { + return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n` +} + +function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string { + const height = int(rng, 1, Math.min(6, dims.rows - 2)) + const lines: string[] = [`\x1b[${height}A\r`] + if (rng() < 0.5) { + lines.push('\x1b[0J') + } + for (let i = 0; i < height; i++) { + lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`) + } + return lines.join('') +} + +function statusLineRewrite(rng: () => number): string { + return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m` +} + +function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string { + const roll = rng() + if (roll < 0.4) { + return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 1, dims.cols)}G` + } + return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}` +} + +function eraseOp(rng: () => number): string { + return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const) +} + +function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string { + const unit = `${textRun(rng)} ` + const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1 + return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n` +} + +function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string { + const top = int(rng, 1, Math.max(1, dims.rows - 4)) + const bottom = int(rng, top + 1, dims.rows) + const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`] + for (let i = 0; i < int(rng, 1, 4); i++) { + body.push(`${textRun(rng)}\r\n`) + } + body.push('\x1b[r') + return body.join('') +} + +function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string { + const rows = int(rng, 2, Math.min(8, dims.rows)) + const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l'] + for (let i = 0; i < rows; i++) { + body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`) + } + body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`) + if (rng() < 0.5) { + body.push('\x1b[?1049l') + } + return body.join('') +} + +function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l` +} + +function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8` +} + +function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string { + const roll = rng() + if (roll < 0.5) { + return `\x1b]0;${textRun(rng)}\x07` + } + if (roll < 0.8) { + return profile.includeOscHyperlinks + ? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07` + : `https://example.com/pr/${int(rng, 1, 999)}\r\n` + } + return '\x1b]133;A\x07' +} + +function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string { + const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h'] + if (profile.includeMouseModes) { + toggles.push( + '\x1b[?1000h', + '\x1b[?1002h', + '\x1b[?1003h', + '\x1b[?1006h', + '\x1b[?1000l', + '\x1b[?1006l' + ) + } + return pick(rng, toggles) +} + +/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have + * historically produced hidden-restore garble (CR rewrites, cursor-up panel + * repaints, DEC 2026 frames, alt-screen churn). */ +function nextOp( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string { + const roll = rng() + if (roll < 0.18) { + return styledLine(rng) + } + if (roll < 0.32) { + return statusLineRewrite(rng) + } + if (roll < 0.46) { + return panelRedraw(rng, dims) + } + if (roll < 0.54) { + return synchronizedFrame(rng, dims) + } + if (roll < 0.62) { + return wrappedLongLine(rng, dims) + } + if (roll < 0.7) { + return cursorMotion(rng, dims) + eraseOp(rng) + } + if (roll < 0.76) { + return altScreenFrame(rng, dims) + } + if (roll < 0.82) { + return scrollRegionBurst(rng, dims) + } + if (roll < 0.88) { + return savedCursorDetour(rng, dims) + } + if (roll < 0.94) { + return oscOp(rng, profile) + } + return modeToggle(rng, profile) +} + +export function buildAgentTuiStreamOps( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string[] { + const ops: string[] = [] + for (let i = 0; i < profile.opCount; i++) { + ops.push(nextOp(rng, dims, profile)) + } + return ops +} + +/** Splits a stream at random boundaries, including inside escape sequences + * (PTY chunking does that constantly). Never splits a surrogate pair: PTY + * bytes are decoded to complete code points before reaching JS strings. */ +export function splitIntoRandomChunks( + rng: () => number, + stream: string, + bounds: { minLen: number; maxLen: number } +): string[] { + const chunks: string[] = [] + let cursor = 0 + while (cursor < stream.length) { + let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen)) + const boundaryCode = stream.charCodeAt(end - 1) + if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) { + end += 1 + } + chunks.push(stream.slice(cursor, end)) + cursor = end + } + return chunks +} diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-output-status.test.ts rename to src/shared/command-code-output-status.test.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/shared/command-code-output-status.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/command-code-output-status.ts rename to src/shared/command-code-output-status.ts index 97e5f00db8b..8bba8a7472b 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -1,3 +1,11 @@ +/** + * Command Code TUI output scrape — that CLI lacks hooks, so working/done + * agent-status rows are seeded from its rendered status words and idle + * composer. Shared because main runs this per-PTY under side-effect authority + * (emitting command-code facts) while the renderer keeps the byte path for + * remote-runtime PTYs and the kill switch + * (docs/reference/terminal-side-effect-authority.md). + */ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate diff --git a/src/renderer/src/components/terminal-pane/command-code-prompt-text.ts b/src/shared/command-code-prompt-text.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-prompt-text.ts rename to src/shared/command-code-prompt-text.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d051d0500bb..f0b6c9dc04e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -309,6 +309,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index 54da72a45f0..e8f34d6bf51 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -3,23 +3,34 @@ export type E2EConfig = { headless: boolean exposeStore: boolean userDataDir: string | null + /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the + * terminal hidden-view parking delays. null means use production timing. */ + terminalParkingDelayMs: number | null } type E2EConfigInput = { headless?: boolean exposeStore?: boolean userDataDir?: string | null + terminalParkingDelayMs?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { const userDataDir = input.userDataDir?.trim() || null const headless = Boolean(input.headless) const exposeStore = Boolean(input.exposeStore) + const terminalParkingDelayMs = + typeof input.terminalParkingDelayMs === 'number' && + Number.isFinite(input.terminalParkingDelayMs) && + input.terminalParkingDelayMs > 0 + ? input.terminalParkingDelayMs + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, - userDataDir + userDataDir, + terminalParkingDelayMs } } diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts new file mode 100644 index 00000000000..a53562f5d5d --- /dev/null +++ b/src/shared/github-links.ts @@ -0,0 +1,102 @@ +// Why shared: main's terminal side-effect tracker emits pr-link facts +// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL +// parsing core the renderer link picker uses. +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubIssueOrPRLink = { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} + +export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { + if (!slug?.owner || !slug.repo) { + return null + } + return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` +} + +function matchGitHubItemPath(url: URL): RegExpExecArray | null { + return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) +} + +function parseGitHubItemNumber(value: string): number | null { + const parsed = Number.parseInt(value, 10) + return parsed > 0 ? parsed : null +} + +/** + * Parses a GitHub issue/PR reference from plain input. + * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. + */ +export function parseGitHubIssueOrPRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return parseGitHubItemNumber(numeric) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return parseGitHubItemNumber(match[4]) +} + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + const number = parseGitHubItemNumber(match[4]) + if (number === null) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', + number + } +} diff --git a/src/shared/osc-title-scan-tail.test.ts b/src/shared/osc-title-scan-tail.test.ts new file mode 100644 index 00000000000..f6afef56f48 --- /dev/null +++ b/src/shared/osc-title-scan-tail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { extractOscTitleScanTail } from './osc-title-scan-tail' + +describe('extractOscTitleScanTail', () => { + it('keeps incomplete OSC title candidates only', () => { + expect(extractOscTitleScanTail('\x1b]0;Codex work')).toBe('\x1b]0;Codex work') + expect(extractOscTitleScanTail('\x1b]2;Codex working\x1b')).toBe('\x1b]2;Codex working\x1b') + expect(extractOscTitleScanTail('\x1b]')).toBe('\x1b]') + expect(extractOscTitleScanTail('\x1b]1')).toBe('\x1b]1') + }) + + it('does not carry non-title OSC payloads into the title scanner', () => { + expect(extractOscTitleScanTail('\x1b]133;D;13')).toBe('') + expect(extractOscTitleScanTail('\x1b]7;file://host/tmp')).toBe('') + expect(extractOscTitleScanTail('\x1b]133;D;0\x07\x1b')).toBe('\x1b') + }) +}) diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts index e37bed4007c..62e49cf9236 100644 --- a/src/shared/osc-title-scan-tail.ts +++ b/src/shared/osc-title-scan-tail.ts @@ -1,18 +1,29 @@ const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 const OSC_TITLE_PREFIX_LENGTH = 4 +const OSC_TITLE_CODES = new Set(['0', '1', '2']) export function extractOscTitleScanTail(input: string): string { const lastOsc = input.lastIndexOf('\x1b]') if (lastOsc !== -1) { const suffix = input.slice(lastOsc) if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) { - return trimOscTitleScanTail(suffix) + return extractIncompleteTitleOscTail(suffix) } return input.endsWith('\x1b') ? '\x1b' : '' } return input.endsWith('\x1b') ? '\x1b' : '' } +function extractIncompleteTitleOscTail(suffix: string): string { + const parameterEnd = suffix.indexOf(';', 2) + if (parameterEnd === -1) { + const partialParameter = suffix.slice(2) + return ['', '0', '1', '2'].includes(partialParameter) ? trimOscTitleScanTail(suffix) : '' + } + const parameter = suffix.slice(2, parameterEnd) + return OSC_TITLE_CODES.has(parameter) ? trimOscTitleScanTail(suffix) : '' +} + function trimOscTitleScanTail(value: string): string { if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { return value diff --git a/src/shared/pty-delivery-diagnostics.test.ts b/src/shared/pty-delivery-diagnostics.test.ts new file mode 100644 index 00000000000..c7d8b6140ec --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.test.ts @@ -0,0 +1,77 @@ +// Why: pins the freeze-report primitives — the breadcrumb ring must stay +// bounded and coalesce repeat events (a flood costs one slot per second, not +// unbounded memory), and pty-id redaction must keep the correlatable suffix +// while dropping the path-bearing worktree prefix. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from './pty-delivery-diagnostics' + +describe('pty delivery breadcrumb ring', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces same-kind events inside the window and separates them outside it', () => { + const ring = createPtyDeliveryBreadcrumbRing(10, 1_000) + ring.record('gate-mark', { id: 'a' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark', { id: 'b' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark') + + let entries = ring.snapshot() + expect(entries).toHaveLength(1) + expect(entries[0].repeats).toBe(3) + // The freshest detail wins so the report shows the latest actor. + expect(entries[0].detail).toEqual({ id: 'b' }) + + vi.advanceTimersByTime(2_000) + ring.record('gate-mark', { id: 'c' }) + entries = ring.snapshot() + expect(entries).toHaveLength(2) + expect(entries[1].repeats).toBeUndefined() + }) + + it('does not coalesce across different kinds and stays bounded at capacity', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('gate-mark') + ring.record('gate-unmark') + expect(ring.snapshot()).toHaveLength(2) + + for (let i = 0; i < 20; i++) { + vi.advanceTimersByTime(2_000) + ring.record(`kind-${i}`) + } + const entries = ring.snapshot() + expect(entries).toHaveLength(5) + expect(entries[4].kind).toBe('kind-19') + }) + + it('snapshot returns copies and reset empties the ring', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('watchdog-heal', { healCount: 1 }) + const entries = ring.snapshot() + entries[0].kind = 'tampered' + expect(ring.snapshot()[0].kind).toBe('watchdog-heal') + ring.reset() + expect(ring.snapshot()).toHaveLength(0) + }) +}) + +describe('redactPtyIdForDiagnostics', () => { + it('keeps the @@ suffix and drops the path-bearing worktree prefix', () => { + expect(redactPtyIdForDiagnostics('/Users/someone/repo@@ab12cd34')).toBe('…@@ab12cd34') + }) + + it('truncates long ids without a separator and passes short ids through', () => { + expect(redactPtyIdForDiagnostics('pty-1')).toBe('pty-1') + const long = 'x'.repeat(40) + expect(redactPtyIdForDiagnostics(long)).toBe(`…${'x'.repeat(12)}`) + }) +}) diff --git a/src/shared/pty-delivery-diagnostics.ts b/src/shared/pty-delivery-diagnostics.ts new file mode 100644 index 00000000000..1b8b5111763 --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.ts @@ -0,0 +1,107 @@ +/** + * One-paste terminal freeze diagnostics: shared shapes for the breadcrumb + * rings kept in BOTH processes and for the per-pty delivery table main embeds + * in its debug snapshot. The goal is that a single console command captures + * enough history + state to attribute any frozen-terminal report without + * asking the user for more logs. + */ + +export type PtyDeliveryBreadcrumb = { + atMs: number + kind: string + detail?: Record + /** Same-kind events within the coalesce window fold into this counter. */ + repeats?: number +} + +export type PtyDeliveryBreadcrumbRing = { + record: (kind: string, detail?: PtyDeliveryBreadcrumb['detail']) => void + snapshot: () => PtyDeliveryBreadcrumb[] + reset: () => void +} + +const BREADCRUMB_RING_CAPACITY = 100 +const BREADCRUMB_COALESCE_MS = 1_000 + +// Why a ring with same-kind coalescing: breadcrumbs record rare transitions, +// but a pathological loop (marker flood, gate flapping) must cost one array +// slot + counter bump per second, never unbounded memory or GC churn. +export function createPtyDeliveryBreadcrumbRing( + capacity = BREADCRUMB_RING_CAPACITY, + coalesceMs = BREADCRUMB_COALESCE_MS +): PtyDeliveryBreadcrumbRing { + let entries: PtyDeliveryBreadcrumb[] = [] + return { + record(kind, detail) { + const now = Date.now() + const last = entries.at(-1) + if (last && last.kind === kind && now - last.atMs < coalesceMs) { + last.repeats = (last.repeats ?? 1) + 1 + last.atMs = now + if (detail !== undefined) { + last.detail = detail + } + return + } + entries.push(detail === undefined ? { atMs: now, kind } : { atMs: now, kind, detail }) + if (entries.length > capacity) { + entries = entries.slice(entries.length - capacity) + } + }, + snapshot() { + return entries.map((entry) => ({ ...entry })) + }, + reset() { + entries = [] + } + } +} + +// Why redact: daemon session ids embed worktree paths +// (`${worktreeId}@@${shortUuid}`). The `@@` suffix is unique enough to +// correlate a pty across the renderer/main sections of one report without +// shipping the user's filesystem layout. +export function redactPtyIdForDiagnostics(id: string): string { + const separatorIdx = id.lastIndexOf('@@') + if (separatorIdx !== -1) { + return `…${id.slice(separatorIdx)}` + } + return id.length <= 12 ? id : `…${id.slice(-12)}` +} + +export type PtyPerPtyDeliveryDiagnostics = { + id: string + sentChars: number + ackedChars: number + inFlightChars: number + pendingChars: number + hidden: boolean + visible: boolean + active: boolean + msSinceLastSend: number | null + msSinceLastAck: number | null +} + +export type PtyMainDeliveryDiagnostics = { + appVersion: string + mainUptimeMs: number + windowFocused: boolean | null + windowVisible: boolean | null + windowMinimized: boolean | null + msSinceLastPowerSuspend: number | null + msSinceLastPowerResume: number | null + perPty: PtyPerPtyDeliveryDiagnostics[] + breadcrumbs: PtyDeliveryBreadcrumb[] +} + +export const EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS: PtyMainDeliveryDiagnostics = { + appVersion: '', + mainUptimeMs: 0, + windowFocused: null, + windowVisible: null, + windowMinimized: null, + msSinceLastPowerSuspend: null, + msSinceLastPowerResume: null, + perPty: [], + breadcrumbs: [] +} diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts new file mode 100644 index 00000000000..a182226a0d0 --- /dev/null +++ b/src/shared/pty-model-restore-marker.ts @@ -0,0 +1,19 @@ +/** + * Out-of-band `pty:modelRestoreNeeded` (main → renderer) payload. + * + * Why a dedicated channel instead of an in-band sentinel chunk: an empty + * `pty:data` chunk is indistinguishable from a real chunk whose bytes were + * entirely stripped by renderer-side OSC-9999 cleaning, so an in-band marker + * could spuriously trigger full snapshot restores on visible panes. The + * marker is delivery machinery, not PTY data — remote-runtime transports + * never see it. + */ +export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' | 'delivery-heal' + +export type PtyModelRestoreNeededEvent = { + id: string + reason: PtyModelRestoreReason + /** Main's PTY output sequence at emit time — everything at or before this + * point is only recoverable from the model snapshot. */ + markerSeq?: number +} diff --git a/src/shared/pty-renderer-delivery-health.ts b/src/shared/pty-renderer-delivery-health.ts new file mode 100644 index 00000000000..987759ec464 --- /dev/null +++ b/src/shared/pty-renderer-delivery-health.ts @@ -0,0 +1,45 @@ +/** + * `pty:reportRendererDeliveryState` (renderer → main, invoke) payload/reply. + * + * Why invoke and renderer-initiated: field evidence (v1.4.121-rc.0 snapshot, + * 2026-07-06) proved a wedge where main→renderer push delivery (`pty:data`, + * `pty:requestDeliveryResync`, `pty:modelRestoreNeeded`) goes silently dead + * while renderer→main invoke IPC stays healthy. Every push-initiated recovery + * path (cumulative-ACK self-heal, solicited resync, wake relay) is unreachable + * in that state, so the delivery watchdog reports and heals over invoke — the + * direction proven alive. + */ +export type PtyRendererDeliveryStateReport = { + /** Cumulative chars received per PTY, counted at dispatcher enqueue — + * BEFORE parse-deferred ACK crediting. The gap between main's sentChars + * and this total is bytes provably lost in the push channel, distinct + * from bytes received but still queued for parsing. */ + receivedCharsByPty: Record + /** Cumulative processed (ACK-credited) chars per PTY — same totals the + * ACK path and resync response carry; merging them here is a free extra + * repair lane for the lost-ACK variant. */ + processedCharsByPty: Record + /** Set on the confirming tick: main may write off provably-lost bytes and + * answer with restore markers for the renderer to route locally. */ + heal?: boolean + /** `ipcRenderer.listenerCount('pty:data')` at heal time — discriminates + * "listener detached" from "channel dead" in field logs. */ + rendererPtyDataListenerCount?: number | null +} + +export type PtyDeliveryWriteOff = { + id: string + /** Main's PTY output sequence at write-off — everything at or before this + * is only recoverable from the model snapshot (pulled restore marker). */ + markerSeq?: number + writtenOffChars: number +} + +export type PtyRendererDeliveryHealthReply = { + inFlightTotalChars: number + inFlightPtyCount: number + /** null = no ACK received since main-side counters were (re)created. */ + msSinceLastAck: number | null + /** Present only on a heal report that actually wrote off lost bytes. */ + writtenOff?: PtyDeliveryWriteOff[] +} diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/shared/terminal-bell-detector.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/bell-detector.test.ts rename to src/shared/terminal-bell-detector.test.ts index bd48ba98381..8f1ce7b0bfc 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.test.ts +++ b/src/shared/terminal-bell-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { it('skips ANSI chunks without losing later real bells', () => { diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/shared/terminal-bell-detector.ts similarity index 82% rename from src/renderer/src/components/terminal-pane/bell-detector.ts rename to src/shared/terminal-bell-detector.ts index 1afdb51e28c..cce08a7dbd0 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -2,6 +2,10 @@ * Stateful BEL detector that correctly ignores BEL (0x07) bytes * occurring inside OSC escape sequences. * + * Shared between the renderer transport processor and main's per-PTY + * side-effect tracker (docs/reference/terminal-side-effect-authority.md): + * bell semantics must not drift between the two parsing authorities. + * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state * across invocations so a BEL used as an OSC terminator is never @@ -17,7 +21,10 @@ * that ended mid-escape does not leak into the next stream. */ export type BellDetector = { - chunkContainsBell(data: string): boolean + /** `hints.containsOscIntroducer` lets a caller that already scanned for + * `\x1b]` (the title-extraction gate) share the result instead of paying + * a second includes() pass per chunk on the hot path. */ + chunkContainsBell(data: string, hints?: { containsOscIntroducer?: boolean }): boolean reset(): void } @@ -27,11 +34,11 @@ export function createBellDetector(): BellDetector { let pendingOscEscape = false return { - chunkContainsBell(data: string): boolean { + chunkContainsBell(data: string, hints: { containsOscIntroducer?: boolean } = {}): boolean { if (!inOsc && !pendingEscape && !data.includes('\x07')) { // Why: CSI/plain chunks with no BEL and no OSC start cannot affect // bell state; avoid walking every byte of normal terminal output. - if (!data.includes('\x1b]')) { + if (!(hints.containsOscIntroducer ?? data.includes('\x1b]'))) { pendingEscape = data.endsWith('\x1b') return false } diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts similarity index 100% rename from src/renderer/src/lib/terminal-github-pr-link-detector.test.ts rename to src/shared/terminal-github-pr-link-detector.test.ts diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts similarity index 91% rename from src/renderer/src/lib/terminal-github-pr-link-detector.ts rename to src/shared/terminal-github-pr-link-detector.ts index bd3cedbcc5b..ee7d92e60c6 100644 --- a/src/renderer/src/lib/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,12 @@ +/** + * Chunk-boundary-safe GitHub PR URL scan over PTY output. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the + * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off + * path. Both paths must share the carry/dedupe semantics or links split across + * chunks would resolve differently per authority mode. + */ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts new file mode 100644 index 00000000000..e4b9cdb8129 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.ts @@ -0,0 +1,108 @@ +/** + * Chunk-boundary-safe OSC 133;D (command finished) scanner. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while + * the renderer keeps byte-parsing for remote-runtime PTYs and the + * kill-switch-off path. The carry semantics (split prefixes, BEL/ST + * terminators, best-effort exit codes) must be identical in both. + */ + +type OscTerminator = { + index: number + length: number +} + +const OSC_133_PREFIX = '\x1b]133;' +const MAX_OSC_CARRY_LENGTH = 4096 + +function findOscTerminator(data: string, startIndex: number): OscTerminator | null { + const bel = data.indexOf('\x07', startIndex) + const st = data.indexOf('\x1b\\', startIndex) + + if (bel === -1 && st === -1) { + return null + } + if (bel !== -1 && (st === -1 || bel < st)) { + return { index: bel, length: 1 } + } + return { index: st, length: 2 } +} + +function parseBestEffortExitCode(value: string | undefined): number | null { + if (!value) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function findPrefixCarry(data: string): string { + const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) + for (let length = maxCarryLength; length > 0; length -= 1) { + const suffix = data.slice(data.length - length) + if (OSC_133_PREFIX.startsWith(suffix)) { + return suffix + } + } + return '' +} + +export type Osc133CommandFinishedScanner = { + /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ + scan: (data: string) => void + /** Drop the cross-chunk carry (transport teardown / parser reset). */ + reset: () => void +} + +export function createOsc133CommandFinishedScanner( + onCommandFinished: (bestEffortExitCode: number | null) => void, + /** OSC 133;C — the shell exec'd a command; the pane's foreground changed. */ + onCommandStarted?: () => void +): Osc133CommandFinishedScanner { + let carry = '' + + const handleOsc133 = (payload: string): void => { + const [sequence, exitCode] = payload.split(';') + if (sequence === 'C') { + onCommandStarted?.() + return + } + if (sequence === 'D') { + onCommandFinished(parseBestEffortExitCode(exitCode)) + } + } + + const scan = (data: string): void => { + let combined = carry + data + carry = '' + + while (combined.length > 0) { + const start = combined.indexOf(OSC_133_PREFIX) + if (start === -1) { + carry = findPrefixCarry(combined) + return + } + + const payloadStart = start + OSC_133_PREFIX.length + const terminator = findOscTerminator(combined, payloadStart) + if (!terminator) { + carry = combined.slice(start) + if (carry.length > MAX_OSC_CARRY_LENGTH) { + carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) + } + return + } + + handleOsc133(combined.slice(payloadStart, terminator.index)) + combined = combined.slice(terminator.index + terminator.length) + } + } + + return { + scan, + reset() { + carry = '' + } + } +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts new file mode 100644 index 00000000000..d9da1b23928 --- /dev/null +++ b/src/shared/terminal-output-side-effects.test.ts @@ -0,0 +1,222 @@ +// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D +// command-finished and GitHub pr-link scanning to the shared tracker so main +// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary +// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules. +import { describe, expect, it } from 'vitest' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type RecordedEvent = + | ['title', string] + | ['bell'] + | ['finished', number | null] + | ['pr', string, number] + | ['2031-subscribe'] + +function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { + events: RecordedEvent[] + tracker: ReturnType +} { + const events: RecordedEvent[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => events.push(['title', normalized]), + onBell: () => events.push(['bell']), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]), + onMode2031Subscribe: () => events.push(['2031-subscribe']), + ...overrides + }) + return { events, tracker } +} + +describe('createTerminalTitleTracker command-finished facts', () => { + it('emits command-finished with best-effort exit codes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`) + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`) + tracker.handleChunk(`${ESC}]133;D;130${BEL}`) + tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`) + tracker.handleChunk(`${ESC}]133;D${BEL}`) + + expect(events).toEqual([ + ['finished', 0], + ['finished', 130], + ['finished', null], + ['finished', null] + ]) + }) + + it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`chunk${ESC}]133`) + tracker.handleChunk(';D;1') + expect(events).toEqual([]) + tracker.handleChunk(`30${BEL}rest`) + tracker.handleChunk(`${ESC}]133;D;7${ST}`) + + expect(events).toEqual([ + ['finished', 130], + ['finished', 7] + ]) + }) + + it('orders chunk facts titles → command-finished → bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`) + + expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']]) + }) +}) + +describe('createTerminalTitleTracker pr-link facts', () => { + it('emits one fact per PR URL including multiple links in one chunk', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk( + 'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n' + ) + + expect(events).toEqual([ + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['pr', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('PR: https://github.com/acme/orca/pull/4') + expect(events).toEqual([]) + tracker.handleChunk('2\r\n') + tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n') + + expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]]) + }) + + it('skips the 133/URL scans entirely when no consumer is registered', () => { + // Mirrors headless serve: no pty:sideEffect consumer means no callbacks, + // so the scanners must not be created (no carry state, no scan cost). + const titles: string[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => titles.push(normalized) + }) + + tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`) + + expect(titles).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker 2031-subscribe facts', () => { + it('emits a fact per chunk containing a DECSET 2031 subscribe, before the bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hready${BEL}`) + + expect(events).toEqual([['2031-subscribe'], ['bell']]) + }) + + it('detects a subscribe split across chunk boundaries', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?20`) + tracker.handleChunk('31h') + + expect(events).toEqual([['2031-subscribe']]) + }) + + it('ignores DECSET 2031 unsubscribes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031l`) + + expect(events).toEqual([]) + }) + + it('skips the 2031 scan entirely when no consumer is registered', () => { + const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + + tracker.handleChunk(`${ESC}[?2031h`) + + expect(events).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker synthetic-frame isolation', () => { + it('never feeds synthetic frames to the 133/PR scanners', () => { + const { events, tracker } = createRecordingTracker() + + tracker.applySyntheticTitleFrame( + `${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n` + ) + + expect(events).toEqual([['title', '⠋ Cursor Agent']]) + }) + + it('keeps a split 133 carry intact across an interleaved synthetic frame', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`out${ESC}]133;D;`) + // An 80ms spinner tick lands between the two halves of the real OSC. + tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([ + ['title', '⠋ Cursor Agent'], + ['finished', 130] + ]) + }) +}) + +describe('createTerminalTitleTracker transient-fact scanning suppression', () => { + it('skips bell/133/pr-link/2031 while suppressed but keeps title processing', () => { + const { events, tracker } = createRecordingTracker() + + tracker.setTransientFactScanningSuppressed(true) + tracker.handleChunk( + `${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}ding${BEL}https://github.com/acme/orca/pull/42\r\n${ESC}[?2031h` + ) + + expect(events).toEqual([['title', 'zsh']]) + }) + + it('resets cross-chunk carry on un-suppress so a pre-gap half-open OSC cannot swallow bells', () => { + const { events, tracker } = createRecordingTracker() + + // The OSC terminator (and everything after) is lost in a delivery gap + // while scanning is suppressed. + tracker.handleChunk(`${ESC}]0;my long title without terminator`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + // Post-gap: a real standalone bell. A stale "inside OSC" state would + // swallow it. + tracker.handleChunk(`done${BEL}`) + + expect(events).toEqual([['bell']]) + }) + + it('a handoff scan seed re-primes a split sequence so its fact fires once, without a phantom bell', () => { + const { events, tracker } = createRecordingTracker() + + // The 133 intro was consumed before scan authority moved away; the carry + // is reset on un-suppress. The transport hands back the emulator's + // partial escape tail as a seed, so the continuation completes normally: + // one command-finished, and its terminator BEL is NOT a standalone bell. + tracker.handleChunk(`out${ESC}]133;D;`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + tracker.handleChunk(`${ESC}]133;D;`, { titleScanData: '' }) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([['finished', 130]]) + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts new file mode 100644 index 00000000000..02bf28efa80 --- /dev/null +++ b/src/shared/terminal-output-side-effects.ts @@ -0,0 +1,326 @@ +/** + * Shared per-PTY terminal title side-effect tracking — the parser core behind + * both the renderer transport (`createPtyOutputProcessor`) and main's + * per-PTY tracker in `OrcaRuntimeService.onPtyData`. + * + * Why shared: docs/reference/terminal-side-effect-authority.md makes main the + * side-effect parser for every PTY whose bytes transit local main. Title + * semantics (all-titles ordering, cursor-agent literal drop, normalization, + * stale-working-title clearing) must not drift between the two paths. + */ + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + extractAllOscTitles, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from './agent-detection' +import { createBellDetector } from './terminal-bell-detector' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + createTerminalGitHubPRLinkDetector, + type TerminalGitHubPRLink +} from './terminal-github-pr-link-detector' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' + +/** Ms of title-less output after a working title before it is cleared. */ +export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 + +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. Mirrors the range +// clearWorkingIndicators strips in agent-detection.ts. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + +/** + * Strip decorative braille spinner frame glyphs for change comparisons. + * Two working titles that differ only by the animation frame (e.g. + * "⠋ Cursor Agent" vs "⠙ Cursor Agent") compare equal after stripping — + * the gate consumers use to avoid fan-out churn on spinner ticks. + */ +export function stripBrailleSpinnerGlyphs(title: string): string { + return title.replace(BRAILLE_SPINNER_RE, '').trim() +} + +/** Provenance for title/idle facts. `staleWorkingTitleClear` marks facts + * synthesized by the 3s stale-working-title timer rather than observed + * bytes — consumers must not treat them as genuine task completions. */ +export type TerminalTitleFactMeta = { + staleWorkingTitleClear?: boolean +} + +export type TerminalTitleTrackerCallbacks = { + /** + * Fired once per observed OSC title, in byte order — including the + * synthesized cleared title when the stale-working timer fires. + */ + onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** + * Fired once per chunk containing a real BEL (OSC-aware, escape state kept + * across chunks), after the chunk's title facts — the renderer drain order. + */ + onBell?: () => void + /** + * Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's + * best-effort exit code — mirrors the renderer terminal-command-lifecycle + * semantics so the fact path drops stale agent rows exactly like byte mode. + */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, + * deduplicated per tracker like the renderer detector). */ + onPrLink?: (link: TerminalGitHubPRLink) => void + /** + * Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe). + * Lets hidden-delivery-gated renderer views answer the color-scheme query + * without byte access; the reply itself stays with the view. + */ + onMode2031Subscribe?: () => void +} + +export type TerminalTitleTracker = { + /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ + handleChunk: (data: string, options?: { titleScanData?: string }) => void + /** + * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). + * Parsed statelessly — never through the chunk bell detector — so a + * synthetic tick landing between two real chunks that split an OSC cannot + * corrupt the cross-chunk escape state into phantom or swallowed bells. + */ + applySyntheticTitleFrame: (frame: string) => void + /** + * Seed the last-known title for a tracker created mid-session (app relaunch + * with persisted/snapshot titles). No-ops once any title has been observed + * or seeded — live state always wins. Fires no callbacks. + */ + seedInitialTitle: (rawTitle: string) => void + /** Last title surfaced through onTitle, after normalization. */ + getLastNormalizedTitle: () => string | null + /** + * While suppressed, handleChunk skips the four transient-fact scanners + * (bell/133/pr-link/2031) — a thinning transport holds scan authority for + * this PTY and relays their facts itself; feeding the (possibly gapped) + * delivered bytes here would mint phantom or duplicate facts. Title + * processing is unaffected. Un-suppressing resets the scanners' cross-chunk + * carry: their last-fed byte predates the gap. + */ + setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Cancel the stale-title timer and clear accumulated tracker state. */ + dispose: () => void +} + +export function createTerminalTitleTracker( + callbacks: TerminalTitleTrackerCallbacks, + options: { initialTitle?: string } = {} +): TerminalTitleTracker { + const { + onTitle, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onBell, + onCommandFinished, + onPrLink, + onMode2031Subscribe + } = callbacks + const bellDetector = onBell ? createBellDetector() : null + // Why: created only when a consumer exists (like the bell detector) so + // headless serve never pays the per-chunk 133/URL scans. + const commandFinishedScanner = onCommandFinished + ? createOsc133CommandFinishedScanner(onCommandFinished) + : null + let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientFactScanningSuppressed = false + // Why: a DECSET 2031 subscribe can be split across PTY chunks; carry a + // bounded tail between chunks so split sequences still match. + let mode2031ScanTail = '' + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session tracker behaves as if it had observed the pane's + // last live title — parity with the renderer processor's seeding. + let lastEmittedTitle: string | null = + options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null + let staleTitleTimer: ReturnType | null = null + // Why: set while the stale timer's cleared title flows through the agent + // tracker so the resulting idle callback carries timer provenance — the + // renderer must not turn a stale clear into a task-complete notification. + let applyingStaleWorkingTitleClear = false + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.( + title, + applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + }, + onAgentBecameWorking, + onAgentExited, + options.initialTitle + ) + : null + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function applyObservedTitle(rawTitle: string): void { + // Why: cursor-agent re-emits its bare native title many times per turn + // while still working; letting it through would stomp Orca's synthesized + // "⠋ Cursor Agent" spinner state back to agentless within a second. + if (isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) + agentTracker?.handleTitle(rawTitle) + } + + function handleChunk(data: string, options: { titleScanData?: string } = {}): void { + const titleScanData = options.titleScanData ?? data + // Why: this is main's per-chunk hot path — scan for the OSC introducer + // once and share the result with the bell detector's fast-path gate. + const containsOscIntroducer = data.includes('\x1b]') + // Why: the bell detector must consume EVERY chunk so OSC sequences that + // span chunk boundaries keep their escape state, even when the chunk has + // no title. The fact itself is surfaced after the chunk's titles, the + // renderer drain's order (payloads → titles → bell). While suppressed it + // must consume NONE: the delivered bytes may be gapped. + const containsBell = + bellDetector && !transientFactScanningSuppressed + ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) + : false + // Why: feed EVERY OSC title in the chunk in byte order, never just the + // last one. node-pty plus the main-process batch window commonly coalesce + // multiple title updates into a single payload; a last-title reader drops + // intra-chunk working→idle transitions (issue #1083). + const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : [] + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } else if ( + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. + data.length > 0 && + lastEmittedTitle !== null && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + // Why: tag timer-synthesized facts. Main's timer is unthrottled + // (unlike the renderer timers that previously damped this path in + // hidden windows), so a merely-paused agent must be distinguishable + // from a genuine working→idle completion downstream. + applyingStaleWorkingTitleClear = true + try { + onTitle?.(cleared, cleared, { staleWorkingTitleClear: true }) + agentTracker?.handleTitle(cleared) + } finally { + applyingStaleWorkingTitleClear = false + } + } + }, STALE_WORKING_TITLE_TIMEOUT_MS) + } + // Per-chunk fact order: titles → command-finished → pr-link → + // 2031-subscribe → bell. The bell stays last (the renderer drain's + // order); the byte scanners keep their own cross-chunk carry so split + // sequences/URLs still resolve. + if (!transientFactScanningSuppressed) { + commandFinishedScanner?.scan(data) + if (prLinkDetector) { + for (const link of prLinkDetector(data)) { + onPrLink?.(link) + } + } + if (onMode2031Subscribe) { + const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = mode2031Scan.tail + if (mode2031Scan.subscribe) { + onMode2031Subscribe() + } + } + } + if (containsBell) { + onBell?.() + } + } + + function applySyntheticTitleFrame(frame: string): void { + // Why: synthetic frames have an exact main-fabricated shape, so they are + // parsed statelessly here. Feeding them through handleChunk would run the + // stateful bell detector: a tick landing while a REAL OSC is split across + // two chunks would consume the pending escape state, minting a phantom + // bell from the continuation chunk or swallowing a real one. + const titles = extractAllOscTitles(frame) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } + // The deliberate permission BEL rides outside the OSC title sequence. A + // FRESH detector instance keeps the OSC-terminator-vs-bell semantics + // while guaranteeing zero interaction with the chunk detector's state. + // Synthetic frames never reach the 133/PR-link scanners: fabricated bytes + // contain neither and must not perturb their cross-chunk carry state. + if (onBell && createBellDetector().chunkContainsBell(frame)) { + onBell() + } + } + + return { + handleChunk, + applySyntheticTitleFrame, + seedInitialTitle(rawTitle: string): void { + // Why: the cursor-agent literal drop applies to seeds too — restoring + // the bare native title would stomp synthesized spinner state exactly + // like emitting it live would. + if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + agentTracker?.seedTitle(rawTitle) + }, + getLastNormalizedTitle: () => lastEmittedTitle, + setTransientFactScanningSuppressed(suppressed: boolean): void { + if (suppressed === transientFactScanningSuppressed) { + return + } + transientFactScanningSuppressed = suppressed + if (!suppressed) { + // Cross-chunk carry predates the suppressed (gapped) span — a stale + // half-open OSC would swallow real bells; a stale 133/2031 tail or + // URL fragment would mint phantom facts. The PR-link dedup memory is + // lost with the recreate; a re-printed link may re-fire (consumers + // treat pr-link as a latest-association update). + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + if (prLinkDetector) { + prLinkDetector = createTerminalGitHubPRLinkDetector() + } + } + }, + dispose(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + } + } +} diff --git a/src/shared/terminal-reply-query-extraction.ts b/src/shared/terminal-reply-query-extraction.ts new file mode 100644 index 00000000000..0ff95b943aa --- /dev/null +++ b/src/shared/terminal-reply-query-extraction.ts @@ -0,0 +1,160 @@ +// Terminal-output scanning for reply-eliciting query sequences (DSR/CPR, +// DA1/DA2, DECRQM, XTGETTCAP-adjacent CSI queries, OSC 10/11 color probes). +// Shared because both sides must salvage queries out of bytes they are about +// to drop: the renderer's hidden-output restore queue and main's pending-cap +// bulk drop. A swallowed query means the program that sent it waits forever +// for a reply (the bench DSR timeout). +import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +export const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 + +export type ExtractedRendererQueryData = { + statelessQueryData: string + statefulQueryData: string + oscColorQueryData: string + pending: string +} + +export function extractHiddenStartupRendererQueryData( + data: string, + pending: string +): ExtractedRendererQueryData { + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let oscColorQueryData = '' + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf('\x1b', offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + if (input.startsWith('\x1b[', candidateIndex)) { + const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (finalByteIndex === -1) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + const sequence = input.slice(candidateIndex, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData += sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData += sequence + } + offset = finalByteIndex + 1 + continue + } + + if (input.startsWith('\x1b]', candidateIndex)) { + const query = parseTerminalOscColorQuery(input, candidateIndex) + if (query.kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + if (query.kind === 'none') { + offset = candidateIndex + 2 + continue + } + oscColorQueryData += input.slice(candidateIndex, query.endIndex) + offset = query.endIndex + continue + } + + if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + + { + offset = candidateIndex + 1 + continue + } + } + + return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } +} + +export function containsCsiRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function containsStatefulRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function findCsiFinalByteIndex(data: string, offset: number): number { + for (let index = offset; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return index + } + } + return -1 +} + +export function isStatelessRendererReplyCsiQuery(sequence: string): boolean { + if (sequence.endsWith('c')) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' + ) +} + +export function isStatefulRendererReplyCsiQuery(sequence: string): boolean { + return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts new file mode 100644 index 00000000000..5444b701ec2 --- /dev/null +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -0,0 +1,284 @@ +// Renderer-parity headless terminal + snapshot/replay mirrors for the garble +// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the +// main-side and renderer-side fuzz suites both consume it and neither +// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other +// side's sources. +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from './terminal-serialize-absolute-cursor' + +export type ParityTerminal = { + terminal: Terminal + serializeAddon: SerializeAddon +} + +/** Builds an @xterm/headless terminal configured exactly like the renderer + * pane where buffer state is concerned: scrollback + kitty vtExtensions from + * buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon + * (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts). + * Font/cursor/render options are omitted — they never alter buffer cells. */ +export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal { + const terminal = new Terminal({ + cols: dims.cols, + rows: dims.rows, + scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + const serializeAddon = new SerializeAddon() + terminal.loadAddon(serializeAddon) + terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(terminal) + return { terminal, serializeAddon } +} + +export function writeToTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + for (const chunk of chunks) { + await writeToTerminal(terminal, chunk) + } +} + +/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent + * is enforced separately by the production restore path). */ +export function visibleRows(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '') + } + return rows +} + +// xterm attribute color modes (Attributes CM_* in xterm's buffer model). +const COLOR_MODE_P16 = 16777216 +const COLOR_MODE_P256 = 33554432 + +/** Known-legitimate serializer normalization: SerializeAddon re-emits palette + * indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a + * restored cell reports CM_P16 where the live cell reported CM_P256. Both + * modes resolve through the same 16 theme slots — no visual difference. */ +function canonicalColorMode(mode: number, color: number): number { + return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode +} + +/** Per-cell descriptor rows so SGR runs that shift cells are caught even + * when the text matches. Encodes only VISUALLY EFFECTIVE state: + * - glyph cells: char, width, fg, bg, all attribute flags; + * - blank cells (null cells and spaces render identically): width, bg, + * underline/strikethrough (drawn across blanks), and fg only when inverse + * swaps it into the cell background. SerializeAddon legitimately skips + * null cells with cursor motion, dropping their invisible fg/bold/italic + * state, and may materialize a skipped run as plain spaces — neither can + * be seen, so neither may fail the garble gate. + * Trailing default blanks are trimmed: the serializer does not re-emit + * pristine cells past the last written column. */ +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const out: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.baseY + y) + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] + .map((flag) => (flag ? '1' : '0')) + .join('') + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push( + `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` + ) + continue + } + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) + } + const defaultBlank = `▯·w1·b0:-1·00` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + out.push(cells.join('|')) + } + return out +} + +export function cursorPosition(terminal: Terminal): { x: number; y: number } { + return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } +} + +/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): + * null cells touching a soft-wrap boundary do not round-trip. Two confirmed + * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): + * - V1 (cell loss): a wrapped continuation row starting with a NULL cell + * (only erasure creates those — typed spaces have chars ' ') passes the + * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as + * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — + * which clamps at the right margin instead of crossing the wrap boundary, + * overwriting the previous row's last cell and shifting the tail left. + * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null + * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI + * param 0 means 1, so the erase lands one cell right and the first filler + * '-' stays visible on the restored screen. + * The fuzz suites use this predicate to tolerate (and count) exactly these + * divergences without masking unknown ones. */ +export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { + const buffer = terminal.buffer.active + for (let y = 1; y < buffer.length; y++) { + const line = buffer.getLine(y) + if (!line?.isWrapped) { + continue + } + if (line.getCell(0)?.getChars() === '') { + return true + } + const previous = buffer.getLine(y - 1) + let previousIsAllNull = previous !== undefined + for (let x = 0; previous && x < previous.length; x++) { + if (previous.getCell(x)?.getChars() !== '') { + previousIsAllNull = false + break + } + } + if (previousIsAllNull) { + return true + } + } + return false +} + +/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon + * restores content rows; both sides may differ only in trailing blanks). */ +export function normalBufferRowsTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows: string[] = [] + for (let y = 0; y < buffer.length; y++) { + rows.push(buffer.getLine(y)?.translateToString(true) ?? '') + } + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + +// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): +// normal-buffer restores wipe screen+scrollback+home; alt-screen restores +// clear only the alt screen so the normal buffer's scrollback survives. +export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' +export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H' + +// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the +// renderer suite pins equality against the real constant so drift fails fast. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' + +export type ParityMainSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq: number + alternateScreen: boolean + pendingDeliveryStartSeq?: number + /** Mirror of TerminalSnapshot.pendingEscapeTailAnsi: the trailing + * incomplete escape of the hidden byte stream. The restorer writes it + * LAST, after its post-replay resets (Bug E fix). */ + pendingEscapeTailAnsi?: string +} + +/** Mirror of the production main-buffer snapshot the renderer restore path + * consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization + + * rehydrateSequences + absolute-cursor/DECSC epilogue) composed exactly like + * OrcaRuntime.serializeHeadlessTerminalBuffer (normal buffer separated from + * an active alt frame). The renderer fuzz cannot import + * HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */ +export function buildParityMainBufferSnapshot( + parity: ParityTerminal, + seq: number, + opts: { + pendingDeliveryStartSeq?: number + scrollbackRows?: number + /** The hidden byte stream's trailing incomplete escape, exactly as the + * emulator's ingest tracker would have accumulated it. */ + pendingEscapeTail?: string + } = {} +): ParityMainSnapshot { + const { terminal } = parity + const alternateScreen = terminal.buffer.active.type === 'alternate' + const scrollback = opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + // Same composition as HeadlessEmulator.getSnapshot: absolute-cursor CUP for + // the wrap-pending relative-restore defect plus the DECSC register epilogue. + let snapshotAnsi = serializeWithAbsoluteCursor( + parity.serializeAddon, + terminal, + { scrollback }, + readSavedCursorRegister(terminal) + ) + let scrollbackAnsi: string | undefined + if (alternateScreen) { + // Why: HeadlessEmulator splits the normal buffer from the active alt frame; + // rehydrateSequences owns the transition between them. + const marker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(marker) + if (start !== -1) { + scrollbackAnsi = snapshotAnsi.slice(0, start) + snapshotAnsi = snapshotAnsi.slice(start + marker.length) + } + } + const seqs: string[] = [] + if (alternateScreen) { + seqs.push('\x1b[0m\x1b[?1049h') + } + if (terminal.modes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + // Why normal-buffer-only: HeadlessEmulator.getModes reports + // applicationCursor false while the alternate buffer is active, so the + // production rehydrate omits ?1h for alt-screen snapshots. + if (!alternateScreen && terminal.modes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + // Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and + // mouse reporting is input encoding — it cannot alter rendered output. + const snapshot: ParityMainSnapshot = { + data: seqs.join('') + snapshotAnsi, + cols: terminal.cols, + rows: terminal.rows, + seq, + alternateScreen, + ...(scrollbackAnsi !== undefined ? { scrollbackAnsi } : {}) + } + if (opts.pendingDeliveryStartSeq !== undefined) { + snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq + } + if (opts.pendingEscapeTail) { + snapshot.pendingEscapeTailAnsi = opts.pendingEscapeTail + } + return snapshot +} diff --git a/src/shared/terminal-scrollback-policy.test.ts b/src/shared/terminal-scrollback-policy.test.ts index 77bbec61c94..26187b11f77 100644 --- a/src/shared/terminal-scrollback-policy.test.ts +++ b/src/shared/terminal-scrollback-policy.test.ts @@ -6,7 +6,9 @@ import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, legacyTerminalScrollbackBytesToRows, normalizeDesktopTerminalScrollbackRows, - normalizeDesktopTerminalSnapshotRows + normalizeDesktopTerminalSnapshotRows, + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars } from './terminal-scrollback-policy' describe('terminal scrollback policy', () => { @@ -35,6 +37,18 @@ describe('terminal scrollback policy', () => { expect(normalizeDesktopTerminalSnapshotRows(100_000)).toBe(50_000) }) + it('scales the output backlog cap with scrollback rows above a 2 MB floor', () => { + // Default and small scrollbacks stay on the floor; large scrollbacks get + // proportionally more so the cap never drops lines scrollback would keep. + expect(terminalOutputBacklogCapChars(undefined)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(5_000)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars('garbage')).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(25_000)).toBe(3_000_000) + expect(terminalOutputBacklogCapChars(50_000)).toBe(6_000_000) + // Values beyond the settings max clamp like the setting itself does. + expect(terminalOutputBacklogCapChars(1_000_000)).toBe(6_000_000) + }) + it('migrates legacy decimal MB buckets by intent, not byte-to-row math', () => { expect(legacyTerminalScrollbackBytesToRows(undefined)).toBe(5_000) expect(legacyTerminalScrollbackBytesToRows(0)).toBe(5_000) diff --git a/src/shared/terminal-scrollback-policy.ts b/src/shared/terminal-scrollback-policy.ts index 22296036cc8..3c831234113 100644 --- a/src/shared/terminal-scrollback-policy.ts +++ b/src/shared/terminal-scrollback-policy.ts @@ -28,6 +28,23 @@ export function normalizeDesktopTerminalScrollbackRows(value: unknown): number { return clampRows(value, DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN) } +// Why the backlog cap scales with scrollback: pending-output caps exist to +// bound memory while a starved display catches up, but a user who raised +// scrollback to 50k rows can retain more history than the flat 2 MB floor — +// dropping at the floor would discard lines their scrollback would have kept. +// 120 chars/row ≈ 80-col text plus escape-sequence overhead; the cap is a +// memory bound, not an exact retention guarantee. +export const TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS = 2 * 1024 * 1024 +const OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW = 120 + +export function terminalOutputBacklogCapChars(scrollbackRows: unknown): number { + const rows = normalizeDesktopTerminalScrollbackRows(scrollbackRows) + return Math.max( + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + rows * OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW + ) +} + export function normalizeDesktopTerminalSnapshotRows(value: unknown): number | undefined { if (!isFiniteNumber(value)) { return undefined diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts new file mode 100644 index 00000000000..18902b27426 --- /dev/null +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -0,0 +1,95 @@ +// Why this module exists: @xterm/addon-serialize restores the cursor with +// RELATIVE moves (CUD/CUB) computed from where it assumes replay leaves the +// cursor. When the final content row is filled exactly to the right margin, +// replay leaves the fresh terminal wrap-pending (internal x == cols), so the +// relative math lands one column short of the real cursor. Every Orca buffer +// snapshot that will be replayed into another terminal must therefore end +// with an absolute CUP derived from the SOURCE terminal's authoritative +// cursor position. Snapshot producers that also need the VT100 DECSC +// saved-cursor register carried across the restore compose it here too. + +type SerializeCursorTerminal = { + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} + +type BufferSerializer = { + serialize: (opts?: TOpts) => string +} + +/** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ +export type SavedCursorRegister = { x: number; y: number } + +// xterm keeps the DECSC register on each Buffer (savedY is absolute: +// ybase-included). It is not exposed through the public API, so snapshot +// producers read the core buffer directly — `_core.buffer` is the ACTIVE +// buffer, so an alt-screen TUI yields the alternate screen's own register, +// matching the one a post-restore DECRC would consult. +type TerminalWithSavedCursorCore = SerializeCursorTerminal & { + _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } +} + +/** Reads the source terminal's active-buffer DECSC register, or null when it + * is unavailable or indistinguishable from the never-saved default. */ +export function readSavedCursorRegister( + terminal: SerializeCursorTerminal +): SavedCursorRegister | null { + const core = (terminal as TerminalWithSavedCursorCore)._core?.buffer + if ( + typeof core?.savedX !== 'number' || + typeof core.savedY !== 'number' || + typeof core.ybase !== 'number' + ) { + return null + } + // savedY is absolute; DECRC restores it relative to the ybase current at + // restore time, clamping at the top — mirror that clamp here. savedX can be + // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. + const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) + const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) + if (x === 0 && y === 0) { + // Home is xterm's never-saved default: a fresh restore terminal already + // sends DECRC to home, and skipping the injection avoids overwriting the + // fresh terminal's default saved SGR/charset when nothing was ever saved. + return null + } + return { x, y } +} + +export function serializeWithAbsoluteCursor( + serializer: BufferSerializer, + terminal: SerializeCursorTerminal, + opts?: TOpts, + savedCursor?: SavedCursorRegister | null +): string { + const serialized = serializer.serialize(opts) + // Why skip empty snapshots: several callers treat '' as "nothing to + // restore" (e.g. shutdown layout capture drops empty buffers); a bare CUP + // would turn every idle pane into a persisted snapshot. + if (serialized.length === 0) { + return serialized + } + const { cursorX, cursorY } = terminal.buffer.active + // Why skip wrap-pending sources (cursorX == cols): plain replay already + // reproduces that state exactly, while CUP would clamp to the last column + // and clear the pending-wrap flag, changing how the next byte renders. + // The remaining bounds checks are defensive: never emit a clamping CUP. + // The saved-cursor injection is skipped with it — it moves the cursor, so + // it may only ride along when the absolute CUP restores the position after. + if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { + return serialized + } + // Why the DECSC injection: the serialized screen cannot carry the VT100 + // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC + // restored to home and clobbered live cells (Bug D in + // notes/garble-fuzz-divergences.md). Re-establish the register by saving at + // the source's saved position, then CUP back to the real cursor. Saved SGR/ + // charset are not carried — the synthetic ESC 7 saves the serializer's + // final pen, a deliberate position-only fidelity trade. + const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' + // cursorY is viewport-relative (0 at the buffer's base row), which is the + // same coordinate space CUP addresses after replay; scrollback length + // differences between source and destination do not shift it. + return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts new file mode 100644 index 00000000000..1a546324006 --- /dev/null +++ b/src/shared/terminal-side-effect-facts.ts @@ -0,0 +1,56 @@ +/** + * Derived terminal side-effect facts carried on the `pty:sideEffect` channel + * (main → renderer). Events are facts, not decisions: main parses every + * local-daemon/SSH PTY byte exactly once and emits what it observed; the + * renderer store handler owns notification/unread policy. + * See docs/reference/terminal-side-effect-authority.md. + */ + +import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' + +/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not + * observed bytes. Renderer policy clears title/cache state from them but + * must not schedule task-complete notifications or unread attention — a + * merely-paused agent (>3s silent mid-task) is not a completion. */ +export type TerminalSideEffectFact = + | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } + | { kind: 'bell' } + | { kind: 'agent-working' } + | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { kind: 'agent-exited' } + /** OSC 133;D — foreground shell command exited (exit code best-effort). */ + | { kind: 'command-finished'; exitCode: number | null } + /** Carries the parsed link so the renderer store consumer never re-parses + * the URL (parse drift would break the per-PTY dedupe contract). */ + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + /** Command Code output scrape (that CLI lacks hooks). Working seeds the + * agent-status row immediately; done is a hint the renderer settle-checks + * against its live status row before completing the turn. */ + | { kind: 'command-code-working'; prompt: string } + | { kind: 'command-code-done'; prompt: string } + /** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted + * so hidden-delivery-gated views (whose bytes never arrive) can still send + * the theme reply — the reply stays renderer-side because query authority + * belongs to the view (model/view contract invariant 6). */ + | { kind: '2031-subscribe' } + +export type TerminalSideEffectBatch = { + ptyId: string + /** PTY output byte sequence at emission. Replay batches carry the sequence + * their title state was current at, so the handler can drop a replay title + * older than the last live title fact it applied. */ + seq: number + /** Facts from one chunk, in byte order: titles in sequence, then bell. + * Command Code scrape facts trail the chunk's parser facts — their policy + * (status-row seeding) never interacts with title/bell ordering. */ + facts: TerminalSideEffectFact[] + /** True for (re)attach snapshots. Replay batches restore title state only — + * attention facts (bell, agent transitions) never replay. */ + replay?: boolean + /** Main-known attribution from runtime leaf/PTY records (same resolution as + * agent-status events). Absent when main has no binding for the PTY yet. */ + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 58a8a0458a5..07de145031f 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -123,6 +123,21 @@ describe('terminal-stream-protocol', () => { expect(unsubscribe?.streamId).toBe(12) }) + it('round-trips output acknowledgement frames', () => { + const ack = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 12, + seq: 4, + payload: encodeTerminalStreamJson({ bytes: 4096 }) + }) + ) + + expect(ack?.opcode).toBe(TerminalStreamOpcode.Ack) + expect(ack?.streamId).toBe(12) + expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 2f8f4e04247..0011463babe 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -14,7 +14,10 @@ export enum TerminalStreamOpcode { Subscribe = 9, Unsubscribe = 10, SnapshotRequest = 11, - Metadata = 12 + Metadata = 12, + // Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only + // remote-multiplex flow control) renumbers to stay wire-compatible. + Ack = 13 } export type TerminalStreamFrame = { @@ -94,6 +97,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || value === TerminalStreamOpcode.SnapshotRequest || - value === TerminalStreamOpcode.Metadata + value === TerminalStreamOpcode.Metadata || + value === TerminalStreamOpcode.Ack ) } diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..fa587a17b5c --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar + * so main's responder replies byte-identically to a visible renderer xterm. + */ + +/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service + * stores internally (`color.toColorRGB`). */ +export type TerminalViewRgb = [number, number, number] + +export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256 + +export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline' + +/** One app-global snapshot of the renderer's composed terminal appearance — + * per-pane font zoom never affects these, and terminalColorOverrides / + * cursor settings are global, so one push covers all PTYs. */ +export type TerminalViewAttributes = { + foreground: TerminalViewRgb + background: TerminalViewRgb + /** Already blended over the background (xterm ThemeService blends the + * cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */ + cursor: TerminalViewRgb + /** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default + * tail, exactly as the renderer ThemeService resolves them. */ + ansi: TerminalViewRgb[] + /** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR + * ?996n answer: that is computed from background/foreground relative + * luminance like a visible xterm (_reportColorScheme), and the two can + * disagree (e.g. dark terminal theme in light app mode). */ + colorSchemeMode: 'dark' | 'light' + cursorStyle: TerminalViewCursorStyle + cursorBlink: boolean +} + +// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits. +const X_RGB_SPEC_RE = + /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/ +const X_HASH_SPEC_RE = /^[\da-f]+$/ + +/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer + * accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh` + * and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors, + * rgbi:) is rejected exactly like the renderer rejects it. */ +export function parseXColorSpec(spec: string): TerminalViewRgb | null { + if (!spec) { + return null + } + let low = spec.toLowerCase() + if (low.startsWith('rgb:')) { + low = low.slice(4) + const m = X_RGB_SPEC_RE.exec(low) + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535 + return [ + Math.round((Number.parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((Number.parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((Number.parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255) + ] + } + return null + } + if (low.startsWith('#')) { + low = low.slice(1) + if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3 + const result: TerminalViewRgb = [0, 0, 0] + for (let i = 0; i < 3; ++i) { + const c = Number.parseInt(low.slice(adv * i, adv * i + adv), 16) + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8 + } + return result + } + } + return null +} + +function padChannelTo16Bit(value: number): string { + const hex = value.toString(16) + const byte = hex.length < 2 ? `0${hex}` : hex + // Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte + // (XParseColor.toRgbString with bits=16) — pinned reply-format parity. + return byte + byte +} + +/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a + * visible renderer xterm uses in OSC 4/10/11/12 query replies. */ +export function formatXColorRgbSpec(rgb: TerminalViewRgb): string { + return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}` +} + +function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +/** Value equality over the whole snapshot. Lets main's store treat a + * re-push of identical attributes (fresh renderer process: second window, + * reload, macOS re-activation) as a no-op instead of a theme apply. */ +export function terminalViewAttributesEqual( + a: TerminalViewAttributes, + b: TerminalViewAttributes +): boolean { + if (a === b) { + return true + } + if ( + !rgbEqual(a.foreground, b.foreground) || + !rgbEqual(a.background, b.background) || + !rgbEqual(a.cursor, b.cursor) || + a.colorSchemeMode !== b.colorSchemeMode || + a.cursorStyle !== b.cursorStyle || + a.cursorBlink !== b.cursorBlink || + a.ansi.length !== b.ansi.length + ) { + return false + } + for (let i = 0; i < a.ansi.length; i++) { + if (!rgbEqual(a.ansi[i], b.ansi[i])) { + return false + } + } + return true +} + +function isRgbChannel(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 +} + +function validateRgbTriple(value: unknown): TerminalViewRgb | null { + if (!Array.isArray(value) || value.length !== 3) { + return null + } + const [r, g, b] = value + if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) { + return null + } + return [r, g, b] +} + +/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns + * a normalized copy or null — main must never store a malformed palette (a + * wrong color reply is worse than silence, the OSC-11 lesson). */ +export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + const candidate = payload as Record + const foreground = validateRgbTriple(candidate.foreground) + const background = validateRgbTriple(candidate.background) + const cursor = validateRgbTriple(candidate.cursor) + if (!foreground || !background || !cursor) { + return null + } + if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) { + return null + } + const ansi: TerminalViewRgb[] = [] + for (const entry of candidate.ansi) { + const triple = validateRgbTriple(entry) + if (!triple) { + return null + } + ansi.push(triple) + } + if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') { + return null + } + if ( + candidate.cursorStyle !== 'bar' && + candidate.cursorStyle !== 'block' && + candidate.cursorStyle !== 'underline' + ) { + return null + } + if (typeof candidate.cursorBlink !== 'boolean') { + return null + } + return { + foreground, + background, + cursor, + ansi, + colorSchemeMode: candidate.colorSchemeMode, + cursorStyle: candidate.cursorStyle, + cursorBlink: candidate.cursorBlink + } +} diff --git a/src/shared/terminal-webgl-diagnostics.ts b/src/shared/terminal-webgl-diagnostics.ts new file mode 100644 index 00000000000..f63c34f1807 --- /dev/null +++ b/src/shared/terminal-webgl-diagnostics.ts @@ -0,0 +1,26 @@ +/** + * Lib-safe sink for WebGL renderer breadcrumbs (context loss/restore, atlas + * resets). The renderer breadcrumb ring lives in the components layer + * (terminal-freeze-breadcrumbs.ts), which may import from lib/pane-manager but + * not the reverse. This indirection lets lib-layer WebGL code record a crumb + * without a backward import: components registers the recorder at startup; + * until then (and in non-renderer contexts) recording is a silent no-op. + */ + +export type WebglDiagnosticRecorder = ( + kind: string, + detail?: Record +) => void + +let recorder: WebglDiagnosticRecorder | null = null + +export function setTerminalWebglDiagnosticRecorder(next: WebglDiagnosticRecorder | null): void { + recorder = next +} + +export function recordTerminalWebglDiagnostic( + kind: string, + detail?: Record +): void { + recorder?.(kind, detail) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 75f23a7c306..b9df77778c2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2722,6 +2722,28 @@ export type GlobalSettings = { * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking — unmounting long-hidden + * terminal panes while a pane-less watcher keeps PTY side effects alive. + * Defaults to true; `false` disables parking entirely. + * See docs/reference/terminal-hidden-view-parking.md. */ + terminalHiddenViewParking?: boolean + /** Kill switch for main-process terminal side-effect authority: when true + * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from + * the `pty:sideEffect` channel and renderer byte parsers stay unregistered + * for those PTYs; `false` restores renderer byte parsing. + * See docs/reference/terminal-side-effect-authority.md. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): when true + * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte + * delivery to hidden renderer views after model ingestion; reveal restores + * from the model snapshot. `false` restores hidden byte delivery. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for the main model query responder (Phase 5): when true + * (default) AND both Phase-4 gate switches are on, main answers terminal + * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the + * runtime emulator. `false` silences the responder without changing drops. + * See docs/reference/terminal-query-authority.md. */ + terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 90fa83b8741..7e856c8dfae 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -1,8 +1,12 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' import path from 'node:path' +import { + type HiddenPressureOutputMode, + writePressureOutputScript +} from './artificial-opencode-hidden-pressure-script' import { ensureTerminalVisible, getActiveWorktreeId, @@ -47,13 +51,13 @@ type HiddenPressureDeps Promise releaseTerminalAckGate: (page: Page) => Promise resetTerminalPtyOutputDebug: (page: Page) => Promise - waitForMainPtyPressureBacklog: (page: Page) => Promise writeInteractivePromptScript: (scriptPath: string, runId: string) => void } +// Why: the renderer hidden-skip counters are gone with the skip grammar — +// withheld hidden output is observed via main's delivery-drop counters only. type HiddenPressureDebug = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number } type HiddenPressureMeasurement = { @@ -66,66 +70,45 @@ type HiddenPressureMainSnapshot = { peakPendingChars: number peakRendererInFlightChars: number ackGatedFlushSkipCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryGatedPtyCount: number +} + +type HiddenPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number } type HiddenPressureAckGate = { heldAckChars: number } -// Why: this is a throughput/drain metric — the time to switch back and replay the -// full 8MB+ held backlog into xterm, measured through repeated (expensive) terminal -// serialization polls. The real responsiveness guards are the typing-latency -// asserts above (median/worst), which hold. Under 8MB of in-flight backpressure on -// a loaded OSS runner the drain-plus-poll overhead was seen at ~3.2s, so keep a -// ceiling with headroom that still catches an order-of-magnitude regression. -const MAX_HIDDEN_RESTORE_LATENCY_MS = 4_000 +// Why: restore still has to finish promptly, but parallel Electron workers on +// Linux CI can overshoot the 1s product target without a responsiveness regression. +// Main relaxed this to 4s for drain-plus-poll overhead on loaded OSS runners; +// this branch KEEPS the strict budget — the background keep-tail global budget +// bounds the aggregate a reveal drains, so a slow restore here is a regression. +const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in +// main after model ingestion, so renderer-delivery pressure must stay FAR +// below the old 2 MB ACK-backpressure target instead of reaching it. +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key // latency catch the same isolated CI starvation spike; median remains strict. const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 -export function pressureOutputScript(runId: string): string { - return ` -const paneIndex = process.argv[2] ?? '0' -const targetChars = Number(process.argv[3] ?? '0') -const delayMs = Number(process.argv[4] ?? '0') -const header = 'OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' -const chunkBody = '#'.repeat(8192) -let written = 0 -process.stdout.write(header) -function writeMore() { - let canContinue = true - while (canContinue && written < targetChars) { - const frame = String(written).padStart(8, '0') - const chunk = '\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n' - written += chunk.length - canContinue = process.stdout.write(chunk) - } - if (written < targetChars) { - process.stdout.once('drain', writeMore) - return - } - process.stdout.write('OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') -} -setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) -` -} - -export function writePressureOutputScript(scriptPath: string, runId: string): void { - mkdirSync(path.dirname(scriptPath), { recursive: true }) - writeFileSync(scriptPath, pressureOutputScript(runId)) -} - export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, TDebug extends HiddenPressureDebug, TMainPressure extends HiddenPressureMainSnapshot, TAckGate extends HiddenPressureAckGate, - TScheduler + TScheduler extends HiddenPressureSchedulerSnapshot >({ deps, annotationSuffix, hiddenPaneCount, pressureOutputChars, + pressureOutputMode = 'tui', pressureStartDelayMs, testInfo, testRepoPath, @@ -135,6 +118,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix?: string hiddenPaneCount: number pressureOutputChars: number + pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number testInfo: TestInfo testRepoPath: string @@ -164,7 +148,7 @@ export async function runHiddenRealPtyPressureScenario< `.orca-opencode-hidden-pressure-load-${runId}.mjs` ) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( @@ -182,7 +166,11 @@ export async function runHiddenRealPtyPressureScenario< await switchToTypingWorkspace(orcaPage, firstWorktreeId) const typingPtyId = await waitForActivePanePtyId(orcaPage) - const pressureBeforeTyping = await deps.waitForMainPtyPressureBacklog(orcaPage) + // Why: under the Phase-4 hidden-delivery gate the hidden panes' bytes are + // dropped in main after model ingestion, so renderer-delivery pressure + // never builds. Wait for the gate to drop at least one pane's worth of + // output instead of the old 2 MB ACK-backpressure target. + await waitForMainHiddenDeliveryDrops(orcaPage, deps, pressureOutputChars) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -190,6 +178,7 @@ export async function runHiddenRealPtyPressureScenario< runId ) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) const ackGate = await deps.readTerminalAckGateDebug(orcaPage) deps.annotateTypingMeasurement( @@ -198,19 +187,26 @@ export async function runHiddenRealPtyPressureScenario< hiddenPanes.length + 1, measurement, debug, - await deps.readTerminalOutputSchedulerDebug(orcaPage), + scheduler, mainPressure, ackGate ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) - expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) - expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024) - expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) - // Why: median is the robust responsiveness guard — it proves typing stays - // instant even while hidden PTYs replay 8MB+ of ACK-backpressured output. + // Hidden-delivery contract (all pressure modes): bytes never reach the + // renderer — main's drop counter is the withheld-output signal (the + // renderer skip counters were deleted with the skip grammar) — and main's + // renderer-delivery pressure must stay clearly below the old 2 MB + // backpressure target. + expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( + pressureOutputChars + ) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeLessThan( + MAIN_RENDERER_PRESSURE_TARGET_CHARS + ) + // Why: the renderer scheduler queue must stay ~empty (no hidden bytes to + // queue) and must never drop a backlog — strict, per the gate contract. + expect(scheduler?.peakQueuedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(75) // Why: worst *single-key echo* under 8MB synthetic backpressure lands behind // whichever flush it collides with, so on a contended OSS shard it is @@ -230,9 +226,11 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} mainPeakInFlightChars=${ - mainPressure?.peakRendererInFlightChars ?? 0 - } heldAckChars=${ackGate?.heldAckChars ?? 0}` + )}ms hiddenDeliveryDroppedChars=${ + mainPressure?.hiddenDeliveryDroppedChars ?? 0 + } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ + ackGate?.heldAckChars ?? 0 + }` }) expect(restoreLatencyMs).toBeLessThan(MAX_HIDDEN_RESTORE_LATENCY_MS) } finally { @@ -248,6 +246,22 @@ export async function runHiddenRealPtyPressureScenario< } } +// Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 +// gate drops hidden bytes in main, so renderer-delivery pressure never builds; +// readiness is the gate reporting one pane's worth of dropped output. +async function waitForMainHiddenDeliveryDrops( + orcaPage: Page, + deps: { readMainPtyPressureDebug: (page: Page) => Promise }, + pressureOutputChars: number +): Promise { + await expect + .poll( + async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } + ) + .toBeGreaterThanOrEqual(pressureOutputChars) +} + async function measureHiddenOutputRestoreLatency( orcaPage: Page, worktreeId: string, diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts new file mode 100644 index 00000000000..1eb94b3de89 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model' + +export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { + const headerPrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const chunkExpression = + mode === 'plain' + ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'latin' + ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : mode === 'rich-model' + ? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\n'" + : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + return ` +const paneIndex = process.argv[2] ?? '0' +const targetChars = Number(process.argv[3] ?? '0') +const delayMs = Number(process.argv[4] ?? '0') +const header = '${headerPrefix}OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' +const chunkBody = '#'.repeat(8192) +let written = 0 +process.stdout.write(header) +function writeMore() { + let canContinue = true + while (canContinue && written < targetChars) { + const frame = String(written).padStart(8, '0') + const chunk = ${chunkExpression} + written += chunk.length + canContinue = process.stdout.write(chunk) + } + if (written < targetChars) { + process.stdout.once('drain', writeMore) + return + } + process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') +} +setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) +` +} + +export function writePressureOutputScript( + scriptPath: string, + runId: string, + mode: HiddenPressureOutputMode +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, pressureOutputScript(runId, mode)) +} diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index da2bd0f9a0f..1c13a5d26a0 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import path from 'node:path' import { sendToTerminal } from './helpers/terminal' -import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' import { annotateScrollMeasurement, getResponsiveScrollPath, @@ -42,6 +42,7 @@ type MainPressureSchedulerSnapshot = { // Why: peak queued chars is noisy at the byte level on CI, but a coarse cap // still catches renderer queue growth that dropped-backlog/latency checks miss. const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 type MainPressureDeps< TMeasurement, @@ -124,7 +125,7 @@ export async function runMainPressureScenario< const pressureScriptPath = path.join(testRepoPath, `.orca-opencode-pressure-load-${runId}.mjs`) await seedActiveTerminalScrollback(orcaPage, typingPane.ptyId, scrollRunId) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( orcaPage, @@ -272,7 +273,9 @@ function expectMainPressureAndTyping { + await page.evaluate(() => { + const store = window.__store + const state = 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 textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('Active terminal input is unavailable') + } + pane.terminal.focus() + textarea.focus() + }) +} + +export async function focusPane(page: Page, paneKey: string): Promise { + const separator = paneKey.indexOf(':') + const tabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + await page.evaluate( + ({ tabId, leafId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) + if (!manager || !pane) { + throw new Error(`Unable to focus pane ${tabId}:${leafId}`) + } + manager.setActivePane?.(pane.id, { focus: true }) + }, + { tabId, leafId } + ) +} + +export async function ensureActiveWorktreePaneLoad( + page: Page, + paneCount: number +): Promise { + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + let snapshot = await waitForPaneIdentitySnapshot(page, 1) + while (snapshot.panes.length < paneCount) { + await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') + snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + } + return snapshot.panes.slice(0, paneCount).map((pane) => ({ + paneKey: `${snapshot.tabId}:${pane.leafId}`, + ptyId: pane.ptyId ?? '' + })) +} + +export async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +export async function getTerminalContentForPtyId( + page: Page, + ptyId: string, + charLimit = 12_000 +): Promise { + return page.evaluate( + ({ ptyId, charLimit }) => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === ptyId) { + return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) + } + } + } + return '' + }, + { ptyId, charLimit } + ) +} + +export async function waitForTerminalOutputForPtyId( + page: Page, + ptyId: string, + expected: string, + timeoutMs: number +): Promise { + await expect + .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not contain "${expected}"` + }) + .toBe(true) +} diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts new file mode 100644 index 00000000000..c6a9d6e35a4 --- /dev/null +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -0,0 +1,311 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync } from 'node:fs' +import path from 'node:path' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type RevisitPressurePane = { paneKey: string; ptyId: string } + +type RevisitPressureMeasurement = { + medianLatencyMs: number + worstLatencyMs: number + maxTimerDriftMs: number +} + +// Why: the renderer hidden-skip counters were deleted with the skip grammar; +// only the mode-2031 fact-reply counter still exists renderer-side. +type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number } + +type RevisitPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number +} + +type RevisitPressureMainSnapshot = { + peakPendingChars: number + peakRendererInFlightChars: number + ackGatedFlushSkipCount: number +} + +type RevisitPressureAckGate = { heldAckChars: number } + +type RevisitPressureDeps< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +> = { + annotateTypingMeasurement: ( + testInfo: TestInfo, + type: string, + paneCount: number, + measurement: TMeasurement, + debug: TDebug | null, + scheduler: TScheduler | null, + mainPressure: TMainPressure | null, + ackGate: TAckGate | null + ) => void + ensureActiveWorktreePaneLoad: (page: Page, paneCount: number) => Promise + focusPane: (page: Page, paneKey: string) => Promise + holdTerminalAckGate: (page: Page, ptyIds: string[]) => Promise + measureTypingDuringLoad: ( + page: Page, + scriptPath: string, + ptyId: string, + runId: string + ) => Promise + readMainPtyPressureDebug: (page: Page) => Promise + readTerminalAckGateDebug: (page: Page) => Promise + readTerminalOutputSchedulerDebug: (page: Page) => Promise + readTerminalPtyOutputDebug: (page: Page) => Promise + releaseTerminalAckGate: (page: Page) => Promise + resetTerminalPtyOutputDebug: (page: Page) => Promise + waitForMainPtyPressureBacklog: (page: Page) => Promise + writeInteractivePromptScript: (scriptPath: string, runId: string) => void +} + +export async function runRendererBackpressureRevisitScenario< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +>({ + backgroundPaneCount, + deps, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + mainRendererPressureTargetChars, + pressureOutputChars, + orcaPage, + testInfo, + testRepoPath +}: { + backgroundPaneCount: number + deps: RevisitPressureDeps + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + mainRendererPressureTargetChars: number + pressureOutputChars: number + orcaPage: Page + testInfo: TestInfo + testRepoPath: string +}): Promise { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== firstWorktreeId) + expect(Boolean(secondWorktreeId), 'renderer backpressure revisit needs a second worktree').toBe( + true + ) + if (!secondWorktreeId) { + return + } + + const runId = randomUUID() + const typingPtyReadyMarker = `OPENCODE_REVISIT_TYPING_PTY_READY_${runId}` + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, `printf '\\n${typingPtyReadyMarker}\\n'\r`) + await waitForMarkerLatency(orcaPage, typingPtyReadyMarker, 10_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const panes = await deps.ensureActiveWorktreePaneLoad(orcaPage, backgroundPaneCount + 1) + const [revisitPane, ...loadPanes] = panes + await deps.focusPane(orcaPage, revisitPane.paneKey) + + const typingScriptPath = path.join(testRepoPath, `.orca-revisit-typing-${runId}.mjs`) + const pressureScriptPath = path.join(testRepoPath, `.orca-revisit-pressure-${runId}.mjs`) + const revisitMarker = `OPENCODE_REVISIT_READY_${runId}` + const pressureDoneMarker = `OPENCODE_PRESSURE_DONE_${runId}_0` + deps.writeInteractivePromptScript(typingScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') + await deps.resetTerminalPtyOutputDebug(orcaPage) + await deps.holdTerminalAckGate( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + try { + await startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath + }) + const pressureBeforeSwitch = await deps.waitForMainPtyPressureBacklog(orcaPage) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const measurement = await deps.measureTypingDuringLoad( + orcaPage, + typingScriptPath, + typingPtyId, + runId + ) + const duringPressure = await deps.readMainPtyPressureDebug(orcaPage) + const ackGate = await deps.readTerminalAckGateDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + const hiddenDebug = await deps.readTerminalPtyOutputDebug(orcaPage) + deps.annotateTypingMeasurement( + testInfo, + 'opencode-main-pressure-worktree-revisit-typing', + panes.length + 1, + measurement, + hiddenDebug, + scheduler, + duringPressure, + ackGate + ) + + expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure + }) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await deps.focusPane(orcaPage, revisitPane.paneKey) + await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) + const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-marker', + description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( + 1 + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` + }) + expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + + await deps.releaseTerminalAckGate(orcaPage) + await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) + const pressureDrainLatencyMs = await waitForMarkerLatency(orcaPage, pressureDoneMarker, 20_000) + const finalScheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-drain', + description: `panes=${panes.length + 1} drain=${pressureDrainLatencyMs.toFixed( + 1 + )}ms rendererPeakQueuedChars=${finalScheduler?.peakQueuedChars ?? 0} rendererDroppedBacklogs=${ + finalScheduler?.droppedBacklogCount ?? 0 + }` + }) + expect(finalScheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(finalScheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + } finally { + await deps.releaseTerminalAckGate(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, '\x03').catch(() => undefined) + await sendToTerminal(orcaPage, revisitPane.ptyId, '\x03').catch(() => undefined) + await Promise.all( + loadPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) + ) + rmSync(typingScriptPath, { force: true }) + rmSync(pressureScriptPath, { force: true }) + } +} + +async function startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath +}: { + loadPanes: RevisitPressurePane[] + orcaPage: Page + pressureOutputChars: number + pressureScriptPath: string +}): Promise { + await Promise.all( + loadPanes.map((pane, paneIndex) => + sendToTerminal( + orcaPage, + pane.ptyId, + `node ${JSON.stringify(pressureScriptPath)} ${paneIndex} ${pressureOutputChars}\r` + ) + ) + ) +} + +async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +function expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure +}: { + ackGate: RevisitPressureAckGate | null + mainRendererPressureTargetChars: number + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + measurement: TMeasurement + pressureBeforeSwitch: RevisitPressureMainSnapshot + scheduler: RevisitPressureSchedulerSnapshot | null + duringPressure: RevisitPressureMainSnapshot | null +}): void { + expect(pressureBeforeSwitch.peakPendingChars).toBeGreaterThan(0) + expect(pressureBeforeSwitch.ackGatedFlushSkipCount).toBeGreaterThan(0) + expect(duringPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + mainRendererPressureTargetChars + ) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(scheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + expect(measurement.medianLatencyMs).toBeLessThan(maxMedianKeyLatencyMs) + expect(measurement.worstLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + expect(measurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index d1e8cbec3aa..99e31cd96db 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -12,22 +12,23 @@ import { waitForSessionReady } from './helpers/store' import { - getTerminalContent, sendToTerminal, - splitActiveTerminalPane, waitForActivePanePtyId, - waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + waitForActiveTerminalManager } from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForMarkerLatency, + waitForTerminalOutputForPtyId +} from './artificial-opencode-pane-interactions' import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' +import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' +import { runRendererBackpressureRevisitScenario } from './artificial-opencode-revisit-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' -type TerminalLoadPane = { - paneKey: string - ptyId: string -} - type TypingMeasurement = { latencies: number[] medianLatencyMs: number @@ -55,9 +56,10 @@ type SyntheticOpenCodeWindow = Window & { } } +// Why: the renderer hidden-skip grammar is deleted — hidden bytes are dropped +// in main (gate) or ride the background queue. Only the mode-2031 fact-reply +// counter still has a renderer-side producer. type TerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } @@ -98,6 +100,12 @@ type MainPtyPressureDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Phase-4 hidden-delivery gate: bytes dropped in main after model ingestion. + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -110,6 +118,7 @@ const HIDDEN_PRESSURE_START_DELAY_MS = 1200 const DEFAULT_FRAME_COUNT = 180 const DEFAULT_FRAME_INTERVAL_MS = 6 const TIMER_SAMPLE_MS = 16 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: these are regression budgets, not observed baselines. Repeated local // 100-pane OpenCode-scale runs are below 50ms worst-key latency; keep enough // CI headroom while still failing changes that make typing visibly sluggish. @@ -126,6 +135,7 @@ const MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS = 3_000 // without visible typing lag. Keep this as a smoke gate, not a CPU lottery. const MAX_TIMER_DRIFT_MS = 250 const MAX_SCROLL_LATENCY_MS = 150 +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] @@ -212,111 +222,6 @@ function writeInteractivePromptScript(scriptPath: string, runId: string): void { writeFileSync(scriptPath, interactivePromptScript(runId)) } -async function focusActiveTerminalInput(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = 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 textarea = pane?.container.querySelector('.xterm-helper-textarea') - if (!pane || !textarea) { - throw new Error('Active terminal input is unavailable') - } - pane.terminal.focus() - textarea.focus() - }) -} - -async function focusPane(page: Page, paneKey: string): Promise { - const separator = paneKey.indexOf(':') - const tabId = paneKey.slice(0, separator) - const leafId = paneKey.slice(separator + 1) - await page.evaluate( - ({ tabId, leafId }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) - if (!manager || !pane) { - throw new Error(`Unable to focus pane ${tabId}:${leafId}`) - } - manager.setActivePane?.(pane.id, { focus: true }) - }, - { tabId, leafId } - ) -} - -async function ensureActiveWorktreePaneLoad( - page: Page, - paneCount: number -): Promise { - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) - while (snapshot.panes.length < paneCount) { - await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) - } - return snapshot.panes.slice(0, paneCount).map((pane) => ({ - paneKey: `${snapshot.tabId}:${pane.leafId}`, - ptyId: pane.ptyId ?? '' - })) -} - -async function waitForMarkerLatency( - page: Page, - marker: string, - timeoutMs: number -): Promise { - const start = performance.now() - while (performance.now() - start < timeoutMs) { - if ((await getTerminalContent(page, 12_000)).includes(marker)) { - return performance.now() - start - } - await page.waitForTimeout(5) - } - throw new Error(`Timed out waiting for terminal marker ${marker}`) -} - -async function getTerminalContentForPtyId( - page: Page, - ptyId: string, - charLimit = 12_000 -): Promise { - return page.evaluate( - ({ ptyId, charLimit }) => { - for (const manager of window.__paneManagers?.values() ?? []) { - for (const pane of manager.getPanes?.() ?? []) { - if (pane.container?.dataset?.ptyId === ptyId) { - return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) - } - } - } - return '' - }, - { ptyId, charLimit } - ) -} - -async function waitForTerminalOutputForPtyId( - page: Page, - ptyId: string, - expected: string, - timeoutMs: number -): Promise { - await expect - .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { - timeout: timeoutMs, - message: `Terminal PTY ${ptyId} did not contain "${expected}"` - }) - .toBe(true) -} - function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] ?? 0 @@ -429,7 +334,7 @@ async function waitForMainPtyPressureBacklog(page: Page): Promise { lastSnapshot = await readMainPtyPressureDebug(page) return ( - (lastSnapshot?.peakRendererInFlightChars ?? 0) >= 8 * 1024 * 1024 && + (lastSnapshot?.peakRendererInFlightChars ?? 0) >= MAIN_RENDERER_PRESSURE_TARGET_CHARS && (lastSnapshot?.peakPendingChars ?? 0) > 0 && (lastSnapshot?.ackGatedFlushSkipCount ?? 0) > 0 ) @@ -456,14 +361,12 @@ function annotateTypingMeasurement( mainPressure: MainPtyPressureDebugSnapshot | null = null, ackGate: TerminalPtyAckGateSnapshot | null = null ): void { - const hiddenSkipSummary = debug - ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` - : '' + const mode2031Summary = debug ? ` mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` : '' const schedulerSummary = scheduler ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount} mainHiddenGatedPtys=${mainPressure.hiddenDeliveryGatedPtyCount} mainHiddenDroppedChars=${mainPressure.hiddenDeliveryDroppedChars} mainPendingDroppedChars=${mainPressure.pendingDroppedChars}` : '' const ackGateSummary = ackGate ? ` heldAckPtys=${ackGate.heldAckCount} heldAckChars=${ackGate.heldAckChars} gatedAckPtys=${ackGate.gatedPtyCount}` @@ -476,7 +379,7 @@ function annotateTypingMeasurement( 1 )}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(1)}ms samples=${measurement.latencies .map((value) => value.toFixed(1)) - .join(',')}${hiddenSkipSummary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` + .join(',')}${mode2031Summary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` }) } @@ -535,8 +438,7 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({ scheduler, mainPressure ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS) expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS) @@ -571,26 +473,28 @@ async function runConfiguredMainPressureScenario({ maxScrollLatencyMs: MAX_SCROLL_LATENCY_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - focusPane, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForActiveWorktree, - waitForMainPtyPressureBacklog, - waitForSessionReady, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } +const terminalLoadScenarioDeps = { + annotateTypingMeasurement, + ensureActiveWorktreePaneLoad, + focusPane, + holdTerminalAckGate, + measureTypingDuringLoad, + readMainPtyPressureDebug, + readTerminalAckGateDebug, + readTerminalOutputSchedulerDebug, + readTerminalPtyOutputDebug, + releaseTerminalAckGate, + resetTerminalPtyOutputDebug, + waitForActiveWorktree, + waitForMainPtyPressureBacklog, + waitForSessionReady, + writeInteractivePromptScript +} + test.describe('Artificial OpenCode terminal load', () => { test.describe.configure({ mode: 'serial' }) @@ -690,6 +594,25 @@ test.describe('Artificial OpenCode terminal load', () => { }) }) + test('keeps renderer backpressure bounded across worktree revisit', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runRendererBackpressureRevisitScenario({ + backgroundPaneCount: PRESSURE_BACKGROUND_PANES, + deps: terminalLoadScenarioDeps, + mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, + maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, + maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxTimerDriftMs: MAX_TIMER_DRIFT_MS, + maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, + orcaPage, + pressureOutputChars: PRESSURE_OUTPUT_CHARS, + testInfo, + testRepoPath + }) + }) + for (const paneCount of SCALE_PRESSURE_PANES) { test(`keeps active interactions responsive at ${paneCount} ACK-backpressured OpenCode PTYs`, async ({ orcaPage, @@ -770,7 +693,8 @@ test.describe('Artificial OpenCode terminal load', () => { testRepoPath: string, testInfo: TestInfo, hiddenPaneCount: number, - annotationSuffix?: string + annotationSuffix?: string, + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -778,35 +702,56 @@ test.describe('Artificial OpenCode terminal load', () => { annotationSuffix, hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, + pressureOutputMode, + // Why: the 10s codex startup renderer-query window is deleted — every + // pressure mode measures steady-state model restore with one delay. pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForMainPtyPressureBacklog, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps + }) + } + const hiddenPressureCases: { + title: string + suffix?: string + mode?: HiddenPressureOutputMode + }[] = [ + { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, + // Why: "withholds renderer delivery" — hidden bytes are dropped in main by + // the delivery gate; the renderer no longer skip-scans chunks (Phase 6). + { + title: 'withholds renderer delivery for plain hidden PTY output while preserving restore', + suffix: '-plain', + mode: 'plain' + }, + { + title: 'withholds renderer delivery for Latin hidden PTY output while preserving restore', + suffix: '-latin', + mode: 'latin' + }, + { + title: + 'withholds renderer delivery for title-only hidden PTY output while preserving restore', + suffix: '-title', + mode: 'title' + }, + { + title: 'restores rich hidden model output under ACK-backpressured PTY output', + suffix: '-rich-model', + mode: 'rich-model' + } + ] + for (const hiddenPressureCase of hiddenPressureCases) { + test(hiddenPressureCase.title, async ({ orcaPage, testRepoPath }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + hiddenPressureCase.suffix, + hiddenPressureCase.mode + ) }) } - test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES - ) - }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index da3e58cf3b5..6cddd6399ba 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -26,11 +26,11 @@ export default function globalSetup(): void { // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { - console.log('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') + console.error('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') } else { // Why: --mode e2e is the build-time signal that exposes window.__store; // the explicit env var keeps older local overrides working too. - console.log('[e2e] Building Electron app with electron-vite build --mode e2e...') + console.error('[e2e] Building Electron app with electron-vite build --mode e2e...') execSync('npx electron-vite build --mode e2e', { env: { ...process.env, VITE_EXPOSE_STORE: 'true' }, cwd: root, @@ -39,13 +39,13 @@ export default function globalSetup(): void { // when healthy; global setup should not fail before specs can run. timeout: ELECTRON_E2E_BUILD_TIMEOUT_MS }) - console.log('[e2e] Build complete.') + console.error('[e2e] Build complete.') } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only // for explicit SSH runs. - console.log('[e2e] Building SSH relay bundle for SSH E2E...') + console.error('[e2e] Building SSH relay bundle for SSH E2E...') execSync('pnpm run build:relay', { cwd: root, stdio: 'inherit', @@ -90,9 +90,9 @@ export default function globalSetup(): void { cwd: testRepoDir, stdio: 'pipe' }) - console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) + console.error(`[e2e] Secondary worktree created at ${worktreeDir}`) // Write the test repo path so the fixture can read it writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - console.log(`[e2e] Test repo created at ${testRepoDir}`) + console.error(`[e2e] Test repo created at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 61d4a2315d7..abb797878af 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -32,7 +32,7 @@ export default function globalTeardown(): void { } rmSync(testRepoDir, { recursive: true, force: true }) - console.log(`[e2e] Cleaned up test repo at ${testRepoDir}`) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } rmSync(TEST_REPO_PATH_FILE, { force: true }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 0ed8ed57370..eb49dfeba5e 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -21,23 +21,14 @@ import { type ElectronApplication, type TestInfo } from '@stablyai/playwright-test' -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync -} from 'node:fs' -import { execSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { TEST_REPO_PATH_FILE } from '../global-setup' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' +import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication @@ -51,6 +42,14 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Why: spec-scoped launch env. Mutating process.env at spec module scope + // leaks into other specs when a worker reloads files without replaying the + // first spec's afterAll; per-test launch env cannot leak. + orcaAppExtraEnv: Record + // Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for + // memory benchmarks). Prepended before the main entry so Electron forwards + // them to Chromium without affecting other specs' launches. + orcaAppExtraArgs: string[] // Why: a few IPC repro specs need to launch the Electron app with a scoped // PATH/token environment. Keep this fixture-owned so tests never mutate the // developer's shell or already-running Orca instance. @@ -122,62 +121,6 @@ function forwardElectronProcessLogs(app: ElectronApplication, testInfo: TestInfo }) } -function isValidGitRepo(repoPath: string): boolean { - if (!repoPath || !existsSync(repoPath)) { - return false - } - - try { - return ( - execSync('git rev-parse --is-inside-work-tree', { - cwd: repoPath, - stdio: 'pipe', - encoding: 'utf8' - }).trim() === 'true' - ) - } catch { - return false - } -} - -function createSeededTestRepo(): string { - // Why: realpathSync so the seeded path matches the store's repo.path on - // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the - // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. - const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) - - execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) - - writeFileSync( - path.join(testRepoDir, 'README.md'), - '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' - ) - writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') - writeFileSync( - path.join(testRepoDir, 'package.json'), - `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` - ) - writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') - mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) - writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') - - execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) - - // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid - // colliding on the same temp repo/worktree when workers start together. - const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) - execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { - cwd: testRepoDir, - stdio: 'pipe' - }) - - writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - return testRepoDir -} - /** * Extended Playwright test with Orca-specific fixtures. * @@ -203,7 +146,11 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => { + electronApp: async ( + { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + provideFixture, + testInfo + ) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -240,7 +187,7 @@ export const test = base.extend({ mkdirSync(recordVideoDir, { recursive: true }) } const app = await electron.launch({ - args: getOrcaElectronLaunchArgs(mainPath, headful), + args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)], ...(slowMo > 0 ? { slowMo } : {}), ...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}), // Why: keep NODE_ENV=development so window.__store is exposed and @@ -265,7 +212,8 @@ export const test = base.extend({ !cleanEnv.ORCA_RELAY_PATH ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } : {}), - ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }), + ...orcaAppExtraEnv } }) forwardElectronProcessLogs(app, testInfo) @@ -281,6 +229,8 @@ export const test = base.extend({ dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], launchEnv: [{}, { option: true }], + orcaAppExtraEnv: [{}, { option: true }], + orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts new file mode 100644 index 00000000000..3ae984193fd --- /dev/null +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -0,0 +1,67 @@ +/** + * Seeded git repo for Orca E2E fixtures: creation and validity checks for the + * disposable test repo (plus its secondary worktree) that specs operate on. + */ + +import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { TEST_REPO_PATH_FILE } from '../global-setup' + +export function isValidGitRepo(repoPath: string): boolean { + if (!repoPath || !existsSync(repoPath)) { + return false + } + + try { + return ( + execSync('git rev-parse --is-inside-work-tree', { + cwd: repoPath, + stdio: 'pipe', + encoding: 'utf8' + }).trim() === 'true' + ) + } catch { + return false + } +} + +export function createSeededTestRepo(): string { + // Why: realpathSync so the seeded path matches the store's repo.path on + // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the + // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. + const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) + + execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) + + writeFileSync( + path.join(testRepoDir, 'README.md'), + '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' + ) + writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') + writeFileSync( + path.join(testRepoDir, 'package.json'), + `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` + ) + writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') + mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) + writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') + + execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) + + // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid + // colliding on the same temp repo/worktree when workers start together. + const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) + execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { + cwd: testRepoDir, + stdio: 'pipe' + }) + + writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) + return testRepoDir +} diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts new file mode 100644 index 00000000000..82fdd2678c4 --- /dev/null +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -0,0 +1,290 @@ +/** + * Layer-discriminating input probes for frozen-terminal repro specs. + * + * The field failure (Discord #performance / GitHub #2836 family) is a pane + * that shows content while keystrokes silently vanish. Both drop layers are + * silent today: + * - renderer: transport.sendInput returns false when `!connected || !ptyId` + * (pty-transport.ts) + * - main: pty:write drops when `ptyOwnership` misses the id or the provider + * lookup fails (src/main/ipc/pty.ts writePtyInput) + * + * Direct `window.api.pty.write` bypasses the renderer transport, so: + * direct dead → MAIN-side drop (ownership/provider routing) + * direct alive, transport dead → RENDERER transport unbound + * The ownership-rebuild probe invokes pty:listSessions, which repopulates + * `ptyOwnership` as a side effect — input reviving after it is a smoking gun + * for the missing-ownership drop path. + * + * Two probe families: + * - Page-based (Playwright CDP): for specs whose renderer never crashes. + * - Main-process-based (webContents.executeJavaScript): for post-crash + * phases — a crashed target severs Playwright's CDP session even though + * the app recovers, so the harness must drive the renderer from main. + */ + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { sendToTerminal, waitForTerminalOutput } from './terminal' + +// ─── Page-based probes (healthy CDP session) ──────────────────────── + +export async function probeDirectWrite( + page: Page, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + // \x03\x15 = ETX+NAK (interrupt + kill-line) so a TUI or half-typed line on + // the shell doesn't swallow the probe — same trick discoverActivePtyId uses. + await sendToTerminal(page, ptyId, `\x03\x15echo ${marker}\r`) + try { + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +/** Probe the full chain: focus the visible xterm and type through the keyboard. */ +export async function probeKeyboardType( + page: Page, + marker: string, + timeoutMs = 10_000 +): Promise { + await page.locator('.xterm:visible').first().click() + await page.keyboard.type(`echo ${marker}`, { delay: 20 }) + await page.keyboard.press('Enter') + try { + // Any appearance of the marker proves the roundtrip: xterm does not local- + // echo, so typed characters only render after the PTY echoes them back. + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +export async function probeOwnershipRebuildRevival( + page: Page, + ptyId: string, + marker: string +): Promise { + await page.evaluate(async () => { + await window.api.pty.listSessions() + }) + return probeDirectWrite(page, ptyId, marker) +} + +export async function getStorePtyIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.values(store.getState().ptyIdsByTabId).flat() + }) +} + +// ─── Main-process-based probes (post-renderer-crash) ──────────────── + +async function mainRendererEval( + electronApp: ElectronApplication, + expression: string +): Promise { + return electronApp.evaluate(async ({ BrowserWindow }, expr) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) { + throw new Error('no live window for executeJavaScript probe') + } + return (await win.webContents.executeJavaScript(expr, true)) as T + }, expression) as Promise +} + +export async function mainRendererStoreReady(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `Boolean(window.__store && window.__store.getState().workspaceSessionReady === true)` + ) + } catch { + // executeJavaScript rejects while the document is loading or the window + // is mid-recovery; callers poll, so a false here is just "not yet". + return false + } +} + +export async function mainGetStorePtyIds(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const store = window.__store + if (!store) { return [] } + return Object.values(store.getState().ptyIdsByTabId).flat() + })()` + ) + } catch { + return [] + } +} + +/** Serialize every mounted pane's buffer; marker search doesn't need per-pane precision. */ +export async function mainGetAllTerminalContent(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return '' } + let combined = '' + for (const manager of managers.values()) { + for (const pane of manager.getPanes?.() ?? []) { + combined += '\\n' + (pane.serializeAddon?.serialize?.() ?? '') + } + } + return combined.slice(-8000) + })()` + ) + } catch { + return '' + } +} + +export async function mainWaitForPaneMounted( + electronApp: ElectronApplication, + timeoutMs = 30_000 +): Promise { + await expect + .poll( + async () => { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return 0 } + let count = 0 + for (const manager of managers.values()) { + count += (manager.getPanes?.() ?? []).length + } + return count + })()` + ) + } catch { + return 0 + } + }, + { timeout: timeoutMs, message: 'no terminal pane mounted after renderer recovery' } + ) + .toBeGreaterThan(0) +} + +async function mainWaitForMarker( + electronApp: ElectronApplication, + marker: string, + timeoutMs: number +): Promise { + try { + await expect + .poll(async () => (await mainGetAllTerminalContent(electronApp)).includes(marker), { + timeout: timeoutMs + }) + .toBe(true) + return true + } catch { + return false + } +} + +/** + * Full-chain probe without CDP: xterm's input() feeds terminal.onData → + * transport.sendInput → pty:write, the identical path keystrokes take past + * the DOM keyboard layer (which the pre-crash Playwright baseline covers). + * Why input() and not paste(): bracketed paste mode would wrap the payload + * and make the shell insert the control chars literally instead of executing. + */ +export async function mainProbeTransportPaste( + electronApp: ElectronApplication, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + const fed = await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return false } + for (const manager of managers.values()) { + const pane = manager.getActivePane?.() ?? (manager.getPanes?.() ?? [])[0] + if (pane?.terminal?.input) { + pane.terminal.input('\\x03\\x15echo ${marker}\\r', true) + return true + } + } + return false + })()` + ) + if (!fed) { + return false + } + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeDirectWrite( + electronApp: ElectronApplication, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + await mainRendererEval( + electronApp, + `window.api.pty.write(${JSON.stringify(ptyId)}, ${JSON.stringify(`\x03\x15echo ${marker}\r`)})` + ) + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeOwnershipRebuildRevival( + electronApp: ElectronApplication, + ptyId: string, + marker: string +): Promise { + try { + await mainRendererEval(electronApp, `window.api.pty.listSessions()`) + } catch { + return false + } + return mainProbeDirectWrite(electronApp, ptyId, marker) +} + +// ─── Failure report ───────────────────────────────────────────────── + +/** + * Assemble the failure report for a reproduced frozen pane. Kept in one place + * so every repro spec reports the same layer discrimination. + */ +export function buildFrozenPaneReport( + context: string, + probes: { + directAlive: boolean + transportAlive: boolean + revivedByOwnershipRebuild: boolean + ptyIds: string[] + terminalTail: string + } +): string { + return [ + `REPRODUCED frozen terminal (${context}):`, + ` direct pty:write probe alive: ${probes.directAlive} (false ⇒ MAIN-side drop: ptyOwnership/provider)`, + ` transport (onData→sendInput) probe alive: ${probes.transportAlive} (false with direct alive ⇒ RENDERER transport unbound)`, + ` revived by pty:listSessions ownership rebuild: ${probes.revivedByOwnershipRebuild}`, + ` pane ptyIds: ${JSON.stringify(probes.ptyIds)}`, + ` terminal tail:\n${probes.terminalTail.slice(-600)}` + ].join('\n') +} diff --git a/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts new file mode 100644 index 00000000000..621842e54e8 --- /dev/null +++ b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts @@ -0,0 +1,172 @@ +/** + * Repro spec for the frozen-terminal-after-renderer-recovery report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: pane shows content, shell is alive, daemon output.log does + * not grow while typing — i.e. keystrokes silently vanish somewhere between + * xterm and the PTY. This spec forces the suspected trigger — renderer + * process death followed by the automatic recovery reload + * (createMainWindow.ts scheduleRendererRecovery) — then discriminates which + * layer drops input via the probes in helpers/terminal-input-probes.ts. + * + * Post-crash phases are driven from the MAIN process: a crashed target + * severs Playwright's CDP session even though the app recovers, so page.* + * calls can never observe the recovered renderer (verified empirically — + * main saw did-finish-load while every window handle stayed dead). + */ + +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + discoverActivePtyId, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { + buildFrozenPaneReport, + mainGetAllTerminalContent, + mainGetStorePtyIds, + mainProbeDirectWrite, + mainProbeOwnershipRebuildRevival, + mainProbeTransportPaste, + mainRendererStoreReady, + mainWaitForPaneMounted, + probeKeyboardType +} from './helpers/terminal-input-probes' + +// Why 2, not more: the renderer recovery circuit breaker allows 3 recoveries +// per 60s window (DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES); a third forced +// crash risks tripping it and testing the breaker instead of the reattach. +const CRASH_CYCLES = 2 + +// Recovery = 250ms reload timer + full document reload + session hydration. +const RECOVERY_TIMEOUT_MS = 60_000 + +type CrashProbe = { + processGone: { reason: string; exitCode: number } | null + recoveredLoads: number +} + +declare global { + // eslint-disable-next-line no-var -- main-process global probe for this spec + var __crashProbe: CrashProbe | undefined +} + +/** + * Observe the crash/recovery lifecycle from the MAIN process. Re-arming + * replaces the probe object, so counts reset per cycle; stale listeners from + * a previous arm write only to their own superseded probe object. + */ +async function armMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const probe: CrashProbe = { processGone: null, recoveredLoads: 0 } + globalThis.__crashProbe = probe + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.on('render-process-gone', (_event, details) => { + probe.processGone = { reason: details.reason, exitCode: details.exitCode ?? -1 } + }) + win.webContents.on('did-finish-load', () => { + probe.recoveredLoads += 1 + }) + } + }) +} + +async function readMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => globalThis.__crashProbe ?? { processGone: null, recoveredLoads: 0 } + ) +} + +async function waitForRendererRecovery(electronApp: ElectronApplication): Promise { + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).recoveredLoads, { + timeout: RECOVERY_TIMEOUT_MS, + message: + 'Main process never observed a recovery reload (did-finish-load) after the forced crash — scheduleRendererRecovery did not fire' + }) + .toBeGreaterThan(0) + await expect + .poll(async () => mainRendererStoreReady(electronApp), { + timeout: RECOVERY_TIMEOUT_MS, + message: 'Recovered renderer never reached workspaceSessionReady' + }) + .toBe(true) + await mainWaitForPaneMounted(electronApp) +} + +test.describe('Renderer crash recovery keeps terminal input alive', () => { + test('typing still reaches the PTY after forced renderer crash + auto-reload', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(300_000) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 1, 30_000) + + // Baseline: both the DOM keyboard layer (Playwright-driven, only possible + // pre-crash) and the PTY roundtrip must work before we crash anything, + // otherwise a post-crash failure would be uninterpretable. + const baselinePtyId = await discoverActivePtyId(orcaPage) + expect( + await probeKeyboardType(orcaPage, 'KB_BASELINE_OK'), + 'baseline keyboard input must reach the PTY before any crash is forced' + ).toBe(true) + + for (let cycle = 0; cycle < CRASH_CYCLES; cycle++) { + await armMainProcessCrashProbe(electronApp) + await electronApp.evaluate(({ BrowserWindow }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.forcefullyCrashRenderer() + } + }) + + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).processGone?.reason, { + timeout: 15_000, + message: 'forcefullyCrashRenderer never produced a render-process-gone event' + }) + .toBeTruthy() + + await waitForRendererRecovery(electronApp) + + // Daemon-backed sessions survive renderer death by design, so the pane + // should reattach to the same session rather than spawn a fresh shell. + const postPtyIds = await mainGetStorePtyIds(electronApp) + + const transportAlive = await mainProbeTransportPaste(electronApp, `PASTE_POST_${cycle}_OK`) + const directAlive = + postPtyIds.length > 0 && + (await mainProbeDirectWrite(electronApp, postPtyIds[0], `DIRECT_POST_${cycle}_OK`)) + + if (!transportAlive || !directAlive) { + const revived = + postPtyIds.length > 0 && + (await mainProbeOwnershipRebuildRevival( + electronApp, + postPtyIds[0], + `REVIVED_POST_${cycle}_OK` + )) + const crashReason = + (await readMainProcessCrashProbe(electronApp)).processGone?.reason ?? 'unknown' + throw new Error( + buildFrozenPaneReport( + `crash cycle ${cycle}, baseline ptyId ${baselinePtyId}, crash reason ${crashReason}`, + { + directAlive, + transportAlive, + revivedByOwnershipRebuild: revived, + ptyIds: postPtyIds, + terminalTail: await mainGetAllTerminalContent(electronApp) + } + ) + ) + } + } + }) +}) diff --git a/tests/e2e/restart-restore-terminal-input.spec.ts b/tests/e2e/restart-restore-terminal-input.spec.ts new file mode 100644 index 00000000000..2ded33f0c0a --- /dev/null +++ b/tests/e2e/restart-restore-terminal-input.spec.ts @@ -0,0 +1,259 @@ +/** + * Repro spec for the "starts frozen right after an update" report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: after an app update + relaunch the terminal pane shows + * restored content but typing produces nothing — daemon output.log never + * grows. Restore paints the persisted buffer synchronously BEFORE the + * deferred PTY reattach runs (use-terminal-pane-lifecycle.ts → + * pty-connection.ts), and every reattach-failure branch swallows into null, + * so a failed/stalled attach leaves a live-looking, input-dead pane. + * + * Existing coverage (daemon-live-session-preservation.spec.ts, + * terminal-restart-persistence.spec.ts) asserts restored CONTENT after + * relaunch, but never that INPUT still works. These tests close that gap for + * three real relaunch shapes: + * 1. clean restart with a live daemon session (the update model) + * 2. daemon wedged (SIGSTOP) across the relaunch — the attach stalls while + * restore has already painted; input must recover once the daemon does + * 3. daemon killed between launches — cold-restore + fresh spawn must yield + * a typeable pane + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + buildFrozenPaneReport, + getStorePtyIds, + probeDirectWrite, + probeKeyboardType, + probeOwnershipRebuildRevival +} from './helpers/terminal-input-probes' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +async function bootstrapFirstLaunch( + page: Page, + repoPath: string +): Promise<{ ptyId: string; marker: string }> { + await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + // Why: the separator only appears in daemon session ids. If e2e silently + // ran the local provider, these tests would exercise the wrong restore path. + expect(ptyId, 'expected a daemon-backed PTY session').toContain(PTY_SESSION_ID_SEPARATOR) + const marker = `RESTORE_INPUT_PRE_${Date.now()}` + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + return { ptyId, marker } +} + +async function settleRestoredLaunch(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +/** + * The shared assertion: a restored pane must accept input. Reports layer + * discrimination when it doesn't. + */ +async function expectRestoredPaneAcceptsInput(page: Page, context: string): Promise { + const ptyIds = await getStorePtyIds(page) + const kbAlive = await probeKeyboardType(page, 'KB_RESTORED_OK', 15_000) + const directAlive = + ptyIds.length > 0 && (await probeDirectWrite(page, ptyIds[0], 'DIRECT_RESTORED_OK', 15_000)) + if (!kbAlive || !directAlive) { + const revived = + ptyIds.length > 0 && + (await probeOwnershipRebuildRevival(page, ptyIds[0], 'REVIVED_RESTORED_OK')) + throw new Error( + buildFrozenPaneReport(context, { + directAlive, + transportAlive: kbAlive, + revivedByOwnershipRebuild: revived, + ptyIds, + terminalTail: await getTerminalContent(page) + }) + ) + } +} + +test.describe.configure({ mode: 'serial' }) + +test('restored pane accepts typing after a clean restart with a live daemon session', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + await waitForTerminalOutput(second.page, marker, 15_000) + expect(readDaemonPid(session.userDataDir), 'daemon must survive the restart').toBe(daemonPid) + + await expectRestoredPaneAcceptsInput(second.page, 'clean restart, live daemon session') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('restored pane recovers input after the daemon un-wedges', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'SIGSTOP/SIGCONT are POSIX-only') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let stoppedDaemonPid: number | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Wedge the daemon: it stays alive (socket exists, sessions retained) but + // cannot accept or answer — the shape of a busy/hung daemon during launch. + process.kill(daemonPid, 'SIGSTOP') + stoppedDaemonPid = daemonPid + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + // Field-fidelity check, not a hard gate: does the pane paint restored + // content while its PTY attach cannot complete? That visible-but-dead + // window is exactly what the reporter sees at startup. + const paintedWhileWedged = (await getTerminalContent(second.page)).includes(marker) + + // The relaunching app may classify the stopped daemon as unreachable and + // kill+replace it (daemon hardening) — then SIGCONT throws ESRCH and the + // old sessions are gone. Both shapes must leave the pane typeable, so + // record which one we're in and keep probing. + let daemonReplacedWhileWedged = false + try { + process.kill(daemonPid, 'SIGCONT') + } catch { + daemonReplacedWhileWedged = true + } + stoppedDaemonPid = null + + await expectRestoredPaneAcceptsInput( + second.page, + `daemon wedged during relaunch (painted while wedged: ${paintedWhileWedged}, ` + + `wedged daemon killed+replaced by relaunch: ${daemonReplacedWhileWedged})` + ) + } finally { + if (stoppedDaemonPid !== null) { + try { + process.kill(stoppedDaemonPid, 'SIGCONT') + } catch { + // daemon already gone + } + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('cold-restored pane accepts typing after the daemon died between launches', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX signal semantics keep this deterministic') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // The daemon dies uncleanly between runs (crash, reboot, force-kill). The + // persisted session now references sessions no living daemon holds. + process.kill(daemonPid, 'SIGKILL') + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + await expectRestoredPaneAcceptsInput(second.page, 'daemon killed between launches') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index c8e3beab6e5..f36f44e9445 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, + focusLastTerminalPane, + splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForTerminalOutput @@ -18,6 +20,7 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const KEY_LATENCY_SAMPLES = 'abcdefghij' const MAX_MEDIAN_KEY_LATENCY_MS = 500 const MAX_WORST_KEY_LATENCY_MS = 2_000 +const MIN_HELD_SSH_ACK_CHARS = 256 * 1024 type TypingMeasurement = { latencies: number[] @@ -25,6 +28,20 @@ type TypingMeasurement = { worstLatencyMs: number } +type SshPtyAckGateSnapshot = { + gatedPtyCount: number + heldAckCount: number + heldAckChars: number +} + +type SshPtyAckGateWindow = Window & { + __terminalPtyAckGate?: { + hold: (ptyIds: string[]) => void + release: () => void + snapshot: () => SshPtyAckGateSnapshot + } +} + type ConnectedDockerRemote = { targetId: string repoId: string @@ -56,6 +73,22 @@ function remoteTypingLoadScript(runId: string): string { ].join(';') } +function remoteBackgroundFloodScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + `process.stdout.write('REMOTE_ACK_FLOOD_READY_${runId}\\n')`, + 'let frame = 0', + 'let timer = null', + "const chunk = 'R'.repeat(8192)", + 'function stop() { if (timer) clearInterval(timer); process.exit(0) }', + "function start() { if (timer) return; timer = setInterval(() => { frame += 1; process.stdout.write('REMOTE_ACK_FLOOD_' + frame + '_' + chunk + '\\n') }, 2) }", + "process.stdin.on('data', (chunk) => { if (chunk.includes(String.fromCharCode(3))) stop(); if (chunk.includes('g')) start() })", + "process.on('SIGINT', stop)" + ].join(';') +} + async function connectDockerRemote( page: Page, target: DockerSshRelayTarget @@ -148,6 +181,28 @@ async function measureRemoteTyping( } } +async function holdSshPtyAckGate(page: Page, ptyIds: string[]): Promise { + await page.evaluate((heldPtyIds) => { + const gate = (window as SshPtyAckGateWindow).__terminalPtyAckGate + if (!gate) { + throw new Error('terminal PTY ACK gate is unavailable') + } + gate.hold(heldPtyIds) + }, ptyIds) +} + +async function releaseSshPtyAckGate(page: Page): Promise { + await page.evaluate(() => { + ;(window as SshPtyAckGateWindow).__terminalPtyAckGate?.release() + }) +} + +async function readSshPtyAckGate(page: Page): Promise { + return page.evaluate( + () => (window as SshPtyAckGateWindow).__terminalPtyAckGate?.snapshot() ?? null + ) +} + async function stopRemoteLoad(page: Page, ptyId: string): Promise { await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) } @@ -207,6 +262,84 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps active remote typing responsive while a background SSH PTY stream is ACK-stalled', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let backgroundPtyId: string | null = null + let activePtyId: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + backgroundPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + await execInTerminal( + orcaPage, + backgroundPtyId, + `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) + await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) + await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await focusLastTerminalPane(orcaPage) + activePtyId = await waitForActivePanePtyId(orcaPage, 60_000) + expect(activePtyId).not.toBe(backgroundPtyId) + + const activeRunId = `${runId}_active` + await execInTerminal( + orcaPage, + activePtyId, + `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) + await expect + .poll(async () => (await readSshPtyAckGate(orcaPage))?.heldAckChars ?? 0, { + timeout: 30_000, + message: 'remote background SSH PTY stream did not build held ACK pressure' + }) + .toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + + const measurement = await measureRemoteTyping(orcaPage, activePtyId, activeRunId) + const ackGate = await readSshPtyAckGate(orcaPage) + const summary = `median=${measurement.medianLatencyMs.toFixed( + 1 + )}ms worst=${measurement.worstLatencyMs.toFixed(1)}ms heldAckChars=${ + ackGate?.heldAckChars ?? 0 + } heldPtys=${ackGate?.heldAckCount ?? 0} samples=${measurement.latencies + .map((value) => value.toFixed(1)) + .join(',')}` + console.log(`[docker-ssh-relay-pty-ack-pressure] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-pty-ack-pressure', + description: summary + }) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + + await releaseSshPtyAckGate(orcaPage) + const releasedAckGate = await readSshPtyAckGate(orcaPage) + expect(releasedAckGate?.heldAckChars ?? 0).toBe(0) + } finally { + await releaseSshPtyAckGate(orcaPage).catch(() => undefined) + if (activePtyId) { + await stopRemoteLoad(orcaPage, activePtyId).catch(() => undefined) + } + if (backgroundPtyId) { + await stopRemoteLoad(orcaPage, backgroundPtyId).catch(() => undefined) + } + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps remote typing responsive while relay file streams and git churn are active', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/sustained-agent-typing-load-scripts.ts b/tests/e2e/sustained-agent-typing-load-scripts.ts new file mode 100644 index 00000000000..430ef47da07 --- /dev/null +++ b/tests/e2e/sustained-agent-typing-load-scripts.ts @@ -0,0 +1,135 @@ +/** + * Script generators for the multi-workspace sustained typing-latency bench + * (terminal-multi-workspace-typing-latency.spec.ts): + * + * - a paced agent-TUI load generator that replays the deterministic pipeline + * bench fixture through a real PTY at a fixed byte rate, emulating a Claude + * Code-style agent streaming in another workspace, and + * - a typing echo probe that timestamps each keystroke's arrival at the pty + * into a sidecar JSONL, so a key's total latency decomposes into + * input-half (CDP keydown -> pty stdin) and echo-half (pty echo -> screen). + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +// Why absolute file URL: the generated .mjs scripts run with the disposable +// test repo as cwd, so the fixture builder must be imported by absolute +// specifier (file URL keeps Windows drive-letter paths importable). +const PIPELINE_BENCH_URL = pathToFileURL( + path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'terminal-pipeline-bench.mjs') +).href + +export function sustainedLoadReadyFilePath( + directory: string, + runId: string, + paneIndex: number +): string { + return path.join(directory, `.orca-mwt-load-ready-${runId}-${paneIndex}`) +} + +export function typingProbeReadyMarker(runId: string): string { + return `MWT_TYPING_READY_${runId}` +} + +export function typingKeyMarkerPrefix(runId: string): string { + return `MWT_KEY_${runId}_` +} + +function sustainedAgentLoadScript(runId: string, readyFileDirectory: string): string { + return ` +import { writeFileSync } from 'node:fs' +import { buildFixture } from ${JSON.stringify(PIPELINE_BENCH_URL)} + +const paneIndex = Number(process.argv[2] ?? 0) +const rateKbps = Number(process.argv[3] ?? 256) +const durationS = Number(process.argv[4] ?? 60) + +const cols = process.stdout.columns ?? 80 +const rows = process.stdout.rows ?? 24 +// 2MB of deterministic Claude-Code-shaped frames, replayed in a loop. +const fixture = buildFixture('agent-tui', 2 * 1024 * 1024, cols, rows) + +const TICK_MS = 50 +const chunkChars = Math.max(1, Math.floor((rateKbps * 1024 * TICK_MS) / 1000)) +const writeChunk = (data) => + new Promise((resolve) => { + if (process.stdout.write(data)) { + resolve() + } else { + process.stdout.once('drain', resolve) + } + }) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Readiness signals via the filesystem, not the terminal buffer: with many +// panes the stream scrolls a READY marker out of the buffer's serialize +// window before the spec's sequential checks reach it. +writeFileSync( + ${JSON.stringify(readyFileDirectory)} + '/.orca-mwt-load-ready-${runId}-' + paneIndex, + String(Date.now()) +) +process.stdout.write('${'MWT_LOAD_READY_'}${runId}_' + paneIndex + '\\r\\n') +const deadline = Date.now() + durationS * 1000 +let offset = 0 +while (Date.now() < deadline) { + await writeChunk(fixture.slice(offset, offset + chunkChars)) + offset = (offset + chunkChars) % fixture.length + await sleep(TICK_MS) +} +process.stdout.write('\\x1b[0m\\x1b[?2026l\\r\\nMWT_LOAD_DONE_${runId}_' + paneIndex + '\\r\\n') +` +} + +function typingEchoProbeScript(runId: string, arrivalSidecarPath: string): string { + return ` +import { appendFileSync } from 'node:fs' + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) process.stdin.setRawMode(true) +process.stdin.resume() +let seq = 0 +const interrupt = String.fromCharCode(3) +process.stdout.write('${'MWT_TYPING_READY_'}${runId}\\r\\n') +process.stdin.on('data', (chunk) => { + // One arrival timestamp per chunk: coalesced keystrokes genuinely arrive + // together at the pty, and that coalescing is part of what we measure. + const atMs = Date.now() + if (chunk.includes(interrupt)) { + process.exit(0) + } + for (const char of chunk) { + if (char === '\\r' || char === '\\n') continue + seq += 1 + appendFileSync( + ${JSON.stringify(arrivalSidecarPath)}, + JSON.stringify({ seq, atMs }) + '\\n' + ) + process.stdout.write('\\r\\x1b[2Kmwt prompt ' + seq + ': ' + char + ' ${'MWT_KEY_'}${runId}_' + seq + '\\r\\n') + } +}) +` +} + +export function writeSustainedAgentLoadScript( + scriptPath: string, + runId: string, + readyFileDirectory: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Generated scripts concatenate with '/', which Node's fs accepts on all + // platforms; normalize Windows backslashes out of the baked-in directory. + writeFileSync( + scriptPath, + sustainedAgentLoadScript(runId, readyFileDirectory.replaceAll('\\', '/')) + ) +} + +export function writeTypingEchoProbeScript( + scriptPath: string, + runId: string, + arrivalSidecarPath: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, typingEchoProbeScript(runId, arrivalSidecarPath)) +} diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 74453aa4076..2dccaf2e87c 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { @@ -22,29 +22,23 @@ type HiddenTuiWindow = Window & { __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } + // Why: only the mode-2031 fact-reply counter survives Phase 6 — the + // hidden-skip counters were deleted with the renderer skip grammar. __terminalPtyOutputDebug?: { reset: () => void snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } } } -type HiddenTuiDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type TuiCursorState = { hidden: boolean | null initialized: boolean | null - cursorElementVisible: boolean - cursorCanvasPresent: boolean } +const HIDDEN_FRAME_SCRIPT_DELAY_MS = 750 + function tuiFrame(runId: string, frame: number): string { const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` const rows = [ @@ -79,23 +73,43 @@ function lowRiskRestoreFrame(runId: string, frame: number): string { } async function resetHiddenDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + // Why: under the Phase-4 hidden-delivery gate the withheld-output signal + // lives in main's delivery debug counters, not the renderer skip path. + await window.api.pty.resetRendererDeliveryDebug() }) } function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) } async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) } -async function readHiddenDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped +// in main after model ingestion and never reach the renderer, so "hidden +// output was withheld" is observed via main's dropped-chars counter instead +// of the old renderer hidden-skip counters. +async function readMainHiddenDeliveryDroppedChars(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return snapshot.hiddenDeliveryDroppedChars }) } @@ -120,23 +134,9 @@ async function readTuiCursorState(page: Page): Promise { _core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } } } )._core - const cursorElement = pane.container.querySelector('.xterm-cursor') - const cursorRect = cursorElement?.getBoundingClientRect() - const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null return { hidden: terminalCore?.coreService?.isCursorHidden ?? null, - initialized: terminalCore?.coreService?.isCursorInitialized ?? null, - // Why: a blinking DOM cursor may be transparent during the sampled frame; - // disappearance regressions remove the laid-out cursor element/layer. - cursorElementVisible: - !!cursorElement && - !!cursorRect && - cursorRect.width > 0 && - cursorRect.height > 0 && - cursorStyle?.display !== 'none' && - cursorStyle?.visibility !== 'hidden', - cursorCanvasPresent: - pane.container.querySelector('.xterm-cursor-layer canvas') !== null + initialized: terminalCore?.coreService?.isCursorInitialized ?? null } }) } @@ -245,13 +245,23 @@ test.describe('Hidden terminal TUI visual restore', () => { writeHiddenFrameScript(scriptPath, runId) await resetHiddenDebug(orcaPage) await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — the bulk TUI frames must be + // withheld in main (dropped after model ingestion), not delivered and + // skipped renderer-side. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'visually rich hidden TUI output should stay on the live xterm path' + message: 'visually rich hidden TUI output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(1024) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'visually rich hidden TUI source did not come from headless model' + }) + .toBe('headless') await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -279,6 +289,7 @@ test.describe('Hidden terminal TUI visual restore', () => { hidden: false, initialized: true }) + const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) await testInfo.attach('hidden-tui-restore-final.png', { @@ -288,8 +299,9 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('keeps newer live output correct after hidden output stayed live', async ({ - orcaPage + test('keeps newer live output correct after plain hidden output restores', async ({ + orcaPage, + testRepoPath }, testInfo: TestInfo) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -323,18 +335,20 @@ test.describe('Hidden terminal TUI visual restore', () => { const hiddenFrame = lowRiskRestoreFrame(runId, 40) const liveFrame = lowRiskRestoreFrame(runId, 41) const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + const scriptPath = path.join(testRepoPath, `.orca-low-risk-hidden-${runId}.mjs`) + writeLowRiskFrameScript(scriptPath, hiddenFrame) + await resetHiddenDebug(orcaPage) + await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) - await injectPaneData(orcaPage, paneKey, hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) + // Why: hidden-delivery gate contract — even plain hidden output is + // dropped in main, so the withheld signal is main's dropped counter. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'hidden injected output should stay on the live xterm path for release' + message: 'plain hidden injected output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(0) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -347,7 +361,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'newer live TUI frame did not render after hidden output stayed live' + message: 'newer live TUI frame did not render after hidden output restored' }) .toContain(finalMarker) @@ -361,7 +375,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'live TUI cursor stayed hidden after hidden output stayed live' + message: 'live TUI cursor stayed hidden after hidden output restored' }) .toMatchObject({ hidden: false, @@ -373,9 +387,102 @@ test.describe('Hidden terminal TUI visual restore', () => { path: screenshotPath, contentType: 'image/png' }) + rmSync(scriptPath, { force: true }) }) - test('keeps hidden terminal side effects live while hidden output stays live', async ({ + test('restores rich synchronized TUI output from the headless model', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden rich model pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden rich model restore' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + try { + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + + // Why: hidden-delivery gate contract — synchronized rich frames are + // withheld in main; the headless model snapshot is the restore source. + await expect + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { + timeout: 10_000, + message: 'rich hidden TUI output was not withheld from the renderer' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'rich hidden TUI source did not come from headless model' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'rich headless TUI frame did not restore when visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + await expect + .poll(() => readTuiCursorState(orcaPage), { + timeout: 5_000, + message: 'rich headless TUI cursor stayed hidden after restore' + }) + .toMatchObject({ + hidden: false, + initialized: true + }) + + const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-rich-model-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) @@ -411,12 +518,6 @@ test.describe('Hidden terminal TUI visual restore', () => { await resetHiddenDebug(orcaPage) await writeHiddenSideEffectBurst(orcaPage, hiddenPane.ptyId, hiddenTitle, marker) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'hidden side-effect output should stay on the live xterm path for release' - }) - .toBe(0) await expect .poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), { timeout: 10_000, diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts new file mode 100644 index 00000000000..4bd2e143e13 --- /dev/null +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -0,0 +1,552 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: the parking wiring registers this handle (dev/exposeStore builds only) +// so tests can detect that hidden-view parking is compiled in and which delay +// override the app actually applied. +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { + parkDelayMs?: number + } +} + +// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain +// window. The fast-park override must be scoped to THIS spec's app launches — +// mutating process.env at module scope leaked into later specs when a worker +// reloaded files without replaying this file's afterAll. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const PARKED_FRAME_SCRIPT_DELAY_MS = 750 +const PARKED_FRAME_COUNT = 25 + +function parkedTuiFrame(runId: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const rows = [ + '╭────────────────────────────────────────────────────────────────────╮', + `│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`, + '├──────────────┬──────────────────────┬──────────────────────────────┤', + `│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`, + `│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`, + `│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`, + '╰──────────────┴──────────────────────┴──────────────────────────────╯', + `PARKED_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[10;18H\x1b[?25h', + '\x1b[?2026l' + ].join('') +} + +function writeParkedFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) => + parkedTuiFrame(runId, frame) + ) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +// Deterministic static alt-screen frame for the park/reveal cycle test: rich +// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot +// restore, painted once and held so the on-screen content is stable across +// cycles. No spinner/progress churn — the frame must be byte-identical every +// reveal so drift is detectable. +function cycleReferenceFrame(runId: string): string { + const rows = [ + '╭──────────────────────────────────────────────────────────╮', + `│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`, + '├───────────────┬──────────────────────────────────────────┤', + `│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`, + `│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`, + '╰───────────────┴──────────────────────────────────────────╯', + `CYCLE_REFERENCE_${runId}` + ] + return [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[?25h' + ].join('') +} + +function writeCycleReferenceScript(scriptPath: string, runId: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Paint the frame once, then hold the process open so the alt-screen TUI + // stays on screen (and the parkable PTY session stays alive) across cycles. + writeFileSync( + scriptPath, + `process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n` + ) +} + +// Why: serialize() re-emits the buffer with cursor-restore trailer sequences +// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any +// visible change. Compare the CONTENT rows, not the trailer — strip trailing +// control sequences and normalize whitespace-only tail lines. +function terminalContentRows(serialized: string): string[] { + // eslint-disable-next-line no-control-regex + const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + return stripped + .split(/\r?\n/) + .map((line) => line.replace(/\s+$/, '')) + .filter((line) => line.length > 0) +} + +async function readParkingWiring( + page: Page +): Promise<{ present: boolean; parkDelayMs: number | null }> { + return page.evaluate(() => { + const debug = (window as ParkingDebugWindow).__terminalParkingDebug + return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null } + }) +} + +// Why: the spec lands ahead of the feature wiring. Skip (rather than fail) +// when the app under test does not expose the parking debug handle so this +// file is safe to merge in any order with the wiring branch. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let wiring = await readParkingWiring(page) + while (!wiring.present && Date.now() < deadline) { + await page.waitForTimeout(250) + wiring = await readParkingWiring(page) + } + test.skip( + !wiring.present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +async function waitForTabParked(page: Page, tabId: string): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function getUnreadTerminalTabIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.keys(store.getState().unreadTerminalTabs) + }) +} + +async function isWorktreeUnread(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + return false + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((candidate) => candidate.id === worktreeId) + return worktree?.isUnread === true + }, worktreeId) +} + +async function getTerminalTabTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + const tabs = await getWorktreeTabs(page, worktreeId) + return tabs.find((tab) => tab.id === tabId)?.title ?? null +} + +async function hasPendingStartupCommand(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return false + } + return store.getState().pendingStartupByTabId[tabId] !== undefined + }, tabId) +} + +type ParkableTabSetup = { + worktreeId: string + tabAId: string + tabAPtyId: string +} + +// Why: every scenario starts from the same shape — tab A live in the active +// worktree; callers then create more tabs on top so tab A goes hidden. +async function setUpParkableTabA(page: Page): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1) + const tabAPtyId = tabASnapshot.panes[0]?.ptyId + if (!tabAPtyId) { + throw new Error('parking spec tab A did not bind a PTY') + } + return { + worktreeId, + tabAId: tabASnapshot.tabId, + tabAPtyId + } +} + +test.describe('Terminal hidden view parking', () => { + test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}` + const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`) + writeParkedFrameScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'rich TUI final frame did not render while tab A was visible' + }) + .toContain(finalMarker) + + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const wiring = await readParkingWiring(orcaPage) + testInfo.annotations.push({ + type: 'terminal-parking', + description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` + }) + + // Why: parking must be scoped to the hidden tab — the visible tab keeps + // a live pane manager and xterm. + const tabBState = await readTerminalTabViewState(orcaPage, tabBId) + expect(tabBState.hasManager).toBe(true) + expect(tabBState.paneCount).toBeGreaterThan(0) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealedSnapshot.tabId).toBe(tabAId) + // Why: parking only tears down the renderer view; the PTY session must + // survive so reveal reattaches to the same shell. + expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked rich TUI frame did not restore when the tab was revealed' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + + // Why: the typed marker only appears joined in command *output*, so this + // proves the revealed terminal accepts input end-to-end, not just echo. + const typedMarker = `PARKED_TYPED_OK_${runId}` + const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'revealed terminal did not execute and display typed input' + }) + .toContain(typedMarker) + + const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('parked-tab-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps bell and title side effects live while parked', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabAId) + + const runId = randomUUID() + const parkedTitle = `Parked side effects ${runId}` + const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}` + // Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL + // must not count as a bell), then a content marker for the reveal check. + // The 30s keep-alive stops the shell prompt from overwriting the title + // before the store assertion lands. + const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n` + const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`) + + await expect + .poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), { + timeout: 10_000, + message: 'parked OSC 0 title did not update the tab title in the store' + }) + .toBe(parkedTitle) + await expect + .poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), { + timeout: 10_000, + message: 'parked BEL did not mark the terminal tab unread' + }) + .toBe(true) + await expect + .poll(() => isWorktreeUnread(orcaPage, worktreeId), { + timeout: 10_000, + message: 'parked BEL did not mark the worktree unread' + }) + .toBe(true) + + // Why: side effects must come from the pane-less watcher — the burst must + // not have woken the parked view back up. + expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked side-effect marker did not restore when the tab was revealed' + }) + .toContain(marker) + }) + + test('does not park excluded tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId } = setup + + // Tab C: parking-excluded because it has a pending startup command. Queue + // it after the pane mounted so the mount-time consume cannot drain it. + const tabCId = await createActiveTerminalTab(orcaPage, worktreeId) + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('parking exclusion spec: window.__store is unavailable') + } + store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' }) + }, tabCId) + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + + // Tab B on top hides both A and C. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + await expect + .poll(() => getActiveTabId(orcaPage), { + timeout: 5_000, + message: 'tab B did not stay active while waiting on the parking window' + }) + .toBe(tabBId) + + // Why: tab A parking proves the machinery ran past the delay in this app + // instance, so the tab C assertion below is not vacuously green. + await waitForTabParked(orcaPage, tabAId) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + + // Premise guard: nothing consumed the pending startup while hidden. + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + const tabCState = await readTerminalTabViewState(orcaPage, tabCId) + expect(tabCState.hasManager).toBe(true) + expect(tabCState.paneCount).toBeGreaterThan(0) + }) + + // Drives 25 deterministic park→reveal cycles on a static rich TUI frame and + // asserts every reveal reproduces the SAME content the tab showed while it was + // continuously visible (the never-parked reference). This is the field-garble + // guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator + // snapshot restore + PTY reattach path the fuzz suites model in isolation, and + // fails if any single cycle — or accumulated drift across 25 — garbles a cell. + test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const marker = `CYCLE_REFERENCE_${runId}` + const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`) + writeCycleReferenceScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'cycle reference frame did not render while tab A was visible' + }) + .toContain(marker) + + // Tab B stays visible whenever tab A is parked; toggling the active tab + // between them is the deterministic hide/reveal driver. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + + // One park/reveal cycle to run the frame through the snapshot restore for a + // baseline. Why not compare against the visible-before-park content: an + // alt-screen restore deliberately drops the normal-buffer scrollback + // (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the + // pre-park serialize carries the shell command echo the restore correctly + // omits — that is contract, not garble. Baselining after one reveal makes + // both sides pass through identical machinery, so any later diff is drift. + const runOneParkRevealCycle = async (cycle: number): Promise => { + await activateTerminalTab(orcaPage, tabBId) + await waitForTabParked(orcaPage, tabAId) + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: `cycle ${cycle}: reference frame did not restore on reveal` + }) + .toContain(marker) + const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000)) + // Garble sentinel: the hidden-skip banner must never appear. + expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output') + return rows + } + + const referenceRows = await runOneParkRevealCycle(0) + expect(referenceRows.join('\n')).toContain(marker) + expect(referenceRows.join('\n')).toContain('╭') + + // waitForTabParked (inside runOneParkRevealCycle) throws if the tab never + // parked, so reaching here means the machinery ran every cycle — no + // separate premise guard needed for a vacuous-green check. + const CYCLES = 25 + const mismatches: string[] = [] + for (let cycle = 1; cycle < CYCLES; cycle++) { + const rows = await runOneParkRevealCycle(cycle) + if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) { + mismatches.push( + `cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}` + ) + } + } + + testInfo.annotations.push({ + type: 'terminal-parking-cycles', + description: `cycles=${CYCLES} mismatches=${mismatches.length}` + }) + expect( + mismatches, + `park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}` + ).toEqual([]) + + const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('park-reveal-25-cycles-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 1af4b1ecf41..6452207980f 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -51,17 +51,6 @@ type TerminalRenderDiagnostics = { }[] } -type LongTableDebugWindow = Window & { - __terminalPtyOutputDebug?: { - reset: () => void - snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number - } - } -} - async function setNarrowTerminalViewport(page: Page): Promise { await page.setViewportSize({ width: 900, height: 820 }) await page.waitForTimeout(250) @@ -359,7 +348,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -398,10 +386,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker) expect(restoredPane).toBeDefined() expect(diagnostics.cursorHidden).toBe(false) @@ -426,7 +410,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -467,10 +450,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) // Why: renderer cell metrics can land one column wider in headless runs; // the content and screenshot assertions below cover the actual regression. expect(diagnostics.cols).toBeLessThanOrEqual(112) @@ -504,7 +483,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -571,10 +549,6 @@ test.describe('Terminal long table scroll restore repro', () => { const diagnostics = await readTerminalRenderDiagnostics(orcaPage) const overpaint = await readTerminalRightEdgeOverpaint(orcaPage) const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) expect(diagnostics.cols).toBeLessThanOrEqual(NARROW_TERMINAL_MAX_COLS) expect(wrapDiagnostics.cols).toBeGreaterThanOrEqual(generatedTableWidth) expect(diagnostics.cursorHidden).toBe(false) diff --git a/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts new file mode 100644 index 00000000000..49bb13fc496 --- /dev/null +++ b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts @@ -0,0 +1,602 @@ +/** + * Deterministic reproduction + benchmark for "typing lags while multiple + * workspaces run agents" (the multi-workspace typing-latency complaint). + * + * Unlike the artificial-opencode suite (bounded bursts + held ACK gates), + * this harness runs SUSTAINED paced agent-TUI streams through real PTYs in + * background-workspace panes (and optionally visible splits) with no + * artificial wedges, types at a fixed cadence WITHOUT waiting for each echo + * (real users keep typing), and decomposes every key's latency into: + * input-half = CDP keydown -> byte arrives at the pty (probe sidecar) + * echo-half = pty echo -> marker visible in the xterm buffer + * All three clocks are epoch ms on one machine, so the halves add up. + * + * Scenarios are gated behind ORCA_TYPING_BENCH=1 (they are benchmarks that + * may legitimately "fail" while the bug reproduces, not CI regression gates). + * Entry point: pnpm bench:multi-workspace-typing (see + * config/scripts/run-multi-workspace-typing-bench.mjs for knobs). Results are + * written as JSON to tools/benchmarks/results/ for A/B comparison. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForTerminalOutputForPtyId, + type TerminalLoadPane +} from './artificial-opencode-pane-interactions' +import { + sustainedLoadReadyFilePath, + typingKeyMarkerPrefix, + typingProbeReadyMarker, + writeSustainedAgentLoadScript, + writeTypingEchoProbeScript +} from './sustained-agent-typing-load-scripts' + +const BENCH_ENABLED = process.env.ORCA_TYPING_BENCH === '1' + +function readPositiveInt(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isInteger(value) && value > 0 ? value : fallback +} + +const LOAD_PANES = readPositiveInt('ORCA_TYPING_BENCH_LOAD_PANES', 4) +const LOAD_RATE_KBPS = readPositiveInt('ORCA_TYPING_BENCH_RATE_KBPS', 256) +const KEY_COUNT = readPositiveInt('ORCA_TYPING_BENCH_KEYS', 32) +const KEY_CADENCE_MS = readPositiveInt('ORCA_TYPING_BENCH_KEY_CADENCE_MS', 250) +const CPU_WORKERS = readPositiveInt('ORCA_TYPING_BENCH_CPU_WORKERS', 0) +const BENCH_LABEL = process.env.ORCA_TYPING_BENCH_LABEL ?? 'dev' + +const KEY_CHARS = 'abcdefghijklmnopqrstuvwxyz' +const TIMER_SAMPLE_MS = 16 +const MARKER_SCAN_TRAILING_ROWS = 160 +const ECHO_STRAGGLER_TIMEOUT_MS = 30_000 +// Load must outlive setup (pane splits, worktree switches) plus the typing +// window; generously padded because setup time varies with pane count. +const LOAD_DURATION_S = Math.ceil((KEY_COUNT * KEY_CADENCE_MS) / 1000) + 90 + +const RESULTS_DIR = path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'results') + +type LatencyStats = { + count: number + p50: number + p90: number + p99: number + max: number +} + +type KeySample = { + seq: number + sentAt: number + ptyArrivedAt: number | null + echoSeenAt: number | null +} + +type PacedTypingMeasurement = { + keyCount: number + missingPtyArrivalCount: number + missingEchoCount: number + totalMs: LatencyStats | null + inputHalfMs: LatencyStats | null + echoHalfMs: LatencyStats | null + maxTimerDriftMs: number + samples: KeySample[] +} + +type SchedulerDebugSnapshot = { + queuedChars: number + peakQueuedChars: number + droppedBacklogCount: number +} + +type MainDeliveryDebugSnapshot = { + pendingChars: number + peakPendingChars: number + peakRendererInFlightChars: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryDroppedChars: number + pendingDroppedChars: number +} + +type TypingBenchWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => SchedulerDebugSnapshot + } +} + +function latencyStats(samples: number[]): LatencyStats | null { + if (samples.length === 0) { + return null + } + const sorted = [...samples].sort((a, b) => a - b) + const at = (q: number): number => + sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] + return { + count: sorted.length, + p50: at(0.5), + p90: at(0.9), + p99: at(0.99), + max: sorted.at(-1) ?? 0 + } +} + +async function scanRecentKeyMarkerSeqs( + page: Page, + markerPrefix: string +): Promise<{ seqs: number[]; atMs: number }> { + return page.evaluate( + ({ markerPrefix, trailingRows }) => { + 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 seqs: number[] = [] + if (!pane) { + return { seqs, atMs: Date.now() } + } + // Why trailing rows, not serialize: full-buffer serialization on every + // poll runs on the renderer main thread and would perturb the very + // latency being measured (same rationale as the history-size spec). + const re = new RegExp(`${markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)`, 'g') + const buffer = pane.terminal.buffer.active + const start = Math.max(0, buffer.length - trailingRows) + for (let row = start; row < buffer.length; row += 1) { + const line = buffer.getLine(row)?.translateToString(true) ?? '' + let match: RegExpExecArray | null + while ((match = re.exec(line)) !== null) { + seqs.push(Number(match[1])) + } + } + return { seqs, atMs: Date.now() } + }, + { markerPrefix, trailingRows: MARKER_SCAN_TRAILING_ROWS } + ) +} + +function readKeyArrivalSidecar(sidecarPath: string): Map { + const arrivals = new Map() + let raw = '' + try { + raw = readFileSync(sidecarPath, 'utf8') + } catch { + return arrivals + } + for (const line of raw.split('\n')) { + if (!line.trim()) { + continue + } + try { + const entry = JSON.parse(line) as { seq: number; atMs: number } + arrivals.set(entry.seq, entry.atMs) + } catch { + /* torn tail write; final retry pass re-reads */ + } + } + return arrivals +} + +async function measurePacedTyping( + page: Page, + runId: string, + sidecarPath: string +): Promise { + const markerPrefix = typingKeyMarkerPrefix(runId) + await focusActiveTerminalInput(page) + + const timerDrift = await page.evaluateHandle((sampleMs) => { + let maxTimerDriftMs = 0 + let lastTick = performance.now() + const timer = window.setInterval(() => { + const now = performance.now() + maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTick - sampleMs) + lastTick = now + }, sampleMs) + return { + stop: () => { + window.clearInterval(timer) + return maxTimerDriftMs + } + } + }, TIMER_SAMPLE_MS) + + // Concurrent echo watcher: records the first time each key's marker is + // visible in the buffer, while typing continues at its own cadence. + const echoSeenAt = new Map() + let watching = true + const echoWatcher = (async () => { + while (watching) { + const { seqs, atMs } = await scanRecentKeyMarkerSeqs(page, markerPrefix) + for (const seq of seqs) { + if (!echoSeenAt.has(seq)) { + echoSeenAt.set(seq, atMs) + } + } + await page.waitForTimeout(10) + } + })() + + const sentAtBySeq = new Map() + try { + for (let index = 0; index < KEY_COUNT; index++) { + const seq = index + 1 + const tickStart = Date.now() + sentAtBySeq.set(seq, tickStart) + await page.keyboard.type(KEY_CHARS[index % KEY_CHARS.length]) + const elapsed = Date.now() - tickStart + if (elapsed < KEY_CADENCE_MS) { + await page.waitForTimeout(KEY_CADENCE_MS - elapsed) + } + } + // Wait out stragglers so a slow echo is measured, not dropped. + const stragglerDeadline = Date.now() + ECHO_STRAGGLER_TIMEOUT_MS + while (echoSeenAt.size < KEY_COUNT && Date.now() < stragglerDeadline) { + await page.waitForTimeout(25) + } + } finally { + watching = false + await echoWatcher + } + const maxTimerDriftMs = await timerDrift.evaluate((watcher) => watcher.stop()) + await timerDrift.dispose() + + // The probe appends arrivals asynchronously; re-read until complete or 5s. + let arrivals = readKeyArrivalSidecar(sidecarPath) + const sidecarDeadline = Date.now() + 5_000 + while (arrivals.size < KEY_COUNT && Date.now() < sidecarDeadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + arrivals = readKeyArrivalSidecar(sidecarPath) + } + + const samples: KeySample[] = [] + const totalMs: number[] = [] + const inputHalfMs: number[] = [] + const echoHalfMs: number[] = [] + for (let seq = 1; seq <= KEY_COUNT; seq++) { + const sentAt = sentAtBySeq.get(seq) ?? 0 + const ptyArrivedAt = arrivals.get(seq) ?? null + const seenAt = echoSeenAt.get(seq) ?? null + samples.push({ seq, sentAt, ptyArrivedAt, echoSeenAt: seenAt }) + if (ptyArrivedAt !== null) { + inputHalfMs.push(ptyArrivedAt - sentAt) + } + if (seenAt !== null) { + totalMs.push(seenAt - sentAt) + if (ptyArrivedAt !== null) { + echoHalfMs.push(seenAt - ptyArrivedAt) + } + } + } + + return { + keyCount: KEY_COUNT, + missingPtyArrivalCount: KEY_COUNT - arrivals.size, + missingEchoCount: KEY_COUNT - echoSeenAt.size, + totalMs: latencyStats(totalMs), + inputHalfMs: latencyStats(inputHalfMs), + echoHalfMs: latencyStats(echoHalfMs), + maxTimerDriftMs, + samples + } +} + +async function readSchedulerDebug(page: Page): Promise { + return page.evaluate( + () => (window as TypingBenchWindow).__terminalOutputSchedulerDebug?.snapshot() ?? null + ) +} + +async function readMainDeliveryDebug(page: Page): Promise { + return page.evaluate(async () => window.api.pty.getRendererDeliveryDebugSnapshot()) +} + +async function resetDeliveryDebug(page: Page): Promise { + await page.evaluate(async () => { + ;(window as TypingBenchWindow).__terminalOutputSchedulerDebug?.reset() + await window.api.pty.resetRendererDeliveryDebug() + }) +} + +function spawnCpuPressureWorkers(): ChildProcess[] { + const workerPath = path.resolve( + __dirname, + '..', + '..', + 'tools', + 'benchmarks', + 'cpu-pressure-worker.mjs' + ) + return Array.from({ length: CPU_WORKERS }, () => + spawn(process.execPath, [workerPath, String((LOAD_DURATION_S + 120) * 1000)], { + stdio: 'ignore' + }) + ) +} + +function writeBenchReport( + testInfo: TestInfo, + scenario: string, + measurement: PacedTypingMeasurement, + scheduler: SchedulerDebugSnapshot | null, + mainDelivery: MainDeliveryDebugSnapshot | null +): void { + const report = { + benchmark: 'multi-workspace-typing-latency', + label: BENCH_LABEL, + scenario, + timestamp: new Date().toISOString(), + config: { + loadPanes: LOAD_PANES, + loadRateKbps: LOAD_RATE_KBPS, + keyCount: KEY_COUNT, + keyCadenceMs: KEY_CADENCE_MS, + cpuWorkers: CPU_WORKERS + }, + measurement, + scheduler, + mainDelivery + } + mkdirSync(RESULTS_DIR, { recursive: true }) + const stamp = report.timestamp.replace(/[:.]/g, '-') + const outPath = path.join( + RESULTS_DIR, + `multi-workspace-typing-${BENCH_LABEL}-${scenario}-${stamp}.json` + ) + writeFileSync(outPath, JSON.stringify(report, null, 2)) + const fmt = (stats: LatencyStats | null): string => + stats + ? `p50 ${stats.p50.toFixed(1)}ms p90 ${stats.p90.toFixed(1)}ms max ${stats.max.toFixed(1)}ms` + : 'n/a' + testInfo.annotations.push({ + type: `multi-workspace-typing-${scenario}`, + description: + `total ${fmt(measurement.totalMs)} | input-half ${fmt(measurement.inputHalfMs)} | ` + + `echo-half ${fmt(measurement.echoHalfMs)} | drift ${measurement.maxTimerDriftMs.toFixed(1)}ms | ` + + `missingEcho ${measurement.missingEchoCount} | report ${outPath}` + }) + console.log(`[multi-workspace-typing] ${scenario}: ${testInfo.annotations.at(-1)?.description}`) +} + +async function startSustainedLoadInPanes( + page: Page, + panes: TerminalLoadPane[], + scriptPath: string, + runId: string, + readyFileDirectory: string +): Promise { + for (const [index, pane] of panes.entries()) { + await sendToTerminal( + page, + pane.ptyId, + `node ${JSON.stringify(scriptPath)} ${index} ${LOAD_RATE_KBPS} ${LOAD_DURATION_S}\r` + ) + } + // Readiness is signalled via files, not terminal markers: a streaming pane + // scrolls its READY line out of the buffer before sequential checks get to + // it once several panes start together. + const missingReadyPanes = (): number[] => + panes + .map((_, index) => index) + .filter((index) => !existsSync(sustainedLoadReadyFilePath(readyFileDirectory, runId, index))) + await expect + .poll(() => missingReadyPanes().length, { + timeout: 30_000, + message: `load panes never signalled ready: ${missingReadyPanes().join(', ')}` + }) + .toBe(0) +} + +async function startTypingProbe( + page: Page, + typingPtyId: string, + scriptPath: string, + runId: string +): Promise { + await sendToTerminal(page, typingPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutputForPtyId(page, typingPtyId, typingProbeReadyMarker(runId), 15_000) +} + +function removeLoadReadyFiles(directory: string, runId: string, paneCount: number): void { + for (let index = 0; index < paneCount; index++) { + rmSync(sustainedLoadReadyFilePath(directory, runId, index), { force: true }) + } +} + +async function stopPtysQuietly(page: Page, ptyIds: string[]): Promise { + await Promise.all( + ptyIds.map((ptyId) => sendToTerminal(page, ptyId, '\x03').catch(() => undefined)) + ) +} + +test.describe('Multi-workspace sustained typing latency bench', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(10 * 60 * 1000) + + test('baseline: paced typing with no agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + const runId = randomUUID() + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + try { + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + 'baseline', + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + expect(measurement.totalMs?.p50 ?? Number.POSITIVE_INFINITY).toBeLessThan(250) + } finally { + await stopPtysQuietly(orcaPage, [typingPtyId]) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + } + }) + + test('typing under sustained hidden multi-workspace agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + const typingWorktreeId = await waitForActiveWorktree(orcaPage) + const loadWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== typingWorktreeId) + expect(Boolean(loadWorktreeId), 'bench needs the seeded secondary worktree').toBe(true) + if (!loadWorktreeId) { + return + } + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let loadPanes: TerminalLoadPane[] = [] + try { + await switchToWorktree(orcaPage, loadWorktreeId) + loadPanes = await ensureActiveWorktreePaneLoad(orcaPage, LOAD_PANES) + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + + await switchToWorktree(orcaPage, typingWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(typingWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + await resetDeliveryDebug(orcaPage) + // Load is flowing when the hidden-delivery gate starts dropping the + // background worktree's bytes — the topology the complaint describes. + await expect + .poll( + async () => (await readMainDeliveryDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'hidden load never started flowing' } + ) + .toBeGreaterThan(0) + + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `hidden-load-${LOAD_PANES}x${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + // Hang detector only — the JSON report is the benchmark output. A + // reproduced regression shows up as large percentiles, not a hard fail. + expect(measurement.missingEchoCount).toBe(0) + + await stopPtysQuietly(orcaPage, [typingPtyId]) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await switchToWorktree(orcaPage, loadWorktreeId).catch(() => undefined) + await stopPtysQuietly( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + await switchToWorktree(orcaPage, typingWorktreeId).catch(() => undefined) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, LOAD_PANES) + } + }) + + test('typing under sustained visible split agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let panes: TerminalLoadPane[] = [] + try { + // Pane 0 types; the rest replay the agent stream side by side — the + // "Claude Code running in a visible split" shape. + panes = await ensureActiveWorktreePaneLoad(orcaPage, 2) + const [typingPane, ...loadPanes] = panes + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + await focusPane(orcaPage, typingPane.paneKey) + + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPane.ptyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `visible-split-${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await stopPtysQuietly( + orcaPage, + panes.map((pane) => pane.ptyId) + ) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, panes.length) + } + }) +}) diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts new file mode 100644 index 00000000000..db837c317cb --- /dev/null +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -0,0 +1,360 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +/** + * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY + * and leaf→PTY binding were torn down while the persisted layout `root` kept + * the leaf, so revisiting the tab materialized a permanently blank pane with + * no terminal behind it. + * + * Field state (workspace remote-issue-2, 2026-07-09): root held 3 leaves, + * ptyIdsByLeafId held 2, no daemon session for the third — the close/exit ran + * near a hidden/park boundary. Each test here closes (or exits) a split pane + * at a different phase of the hidden-view parking lifecycle and asserts the + * invariant that broke in the field: + * + * leaves(persisted root) === keys(persisted ptyIdsByLeafId) === live panes + * + * A failing scenario IS the finding — it pins which boundary loses the layout + * collapse. + */ + +// Why 2000ms: the override shrinks BOTH cold-park delay and hot-retain, and +// the hidden-but-mounted scenario needs the shell exit to land well inside the +// hot-retain window — 500ms let slow shell teardown race past parking and turn +// that scenario into the exits-while-parked one. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 2_000 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { parkDelayMs?: number } +} + +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + } + test.skip(!present, 'terminal hidden view parking wiring is not compiled in') +} + +type LayoutConsistency = { + rootLeafIds: string[] + boundLeafIds: string[] + boundPtyIds: string[] + livePaneCount: number | null + hasManager: boolean + domPaneCount: number +} + +async function readLayoutConsistency(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const layout = store.getState().terminalLayoutsByTabId[tabId] + const rootLeafIds: string[] = [] + type LayoutNode = + | { type: 'leaf'; leafId: string } + | { type: 'split'; first: LayoutNode; second: LayoutNode } + const walk = (node: LayoutNode | null | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + rootLeafIds.push(node.leafId) + return + } + walk(node.first) + walk(node.second) + } + walk((layout?.root ?? null) as LayoutNode | null) + const manager = window.__paneManagers?.get(tabId) + const managerPanes = manager?.getPanes?.() ?? null + const paneElements = managerPanes ? new Set(managerPanes.map((pane) => pane.container)) : null + return { + rootLeafIds, + boundLeafIds: Object.keys(layout?.ptyIdsByLeafId ?? {}), + boundPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}), + livePaneCount: managerPanes ? managerPanes.length : null, + hasManager: manager !== undefined, + domPaneCount: paneElements + ? Array.from(document.querySelectorAll('.pane[data-leaf-id]')).filter( + (element) => paneElements.has(element) + ).length + : 0 + } + }, tabId) +} + +/** + * The invariant under hunt. Polls so post-close persists can land, then does a + * final full read whose diff names the divergence (stale root leaf vs dropped + * binding vs ghost live pane). + */ +async function expectLayoutConsistent( + page: Page, + tabId: string, + expectedPaneCount: number, + phase: string, + deadPtyId?: string +): Promise { + // Why: polling a shape (not a boolean) makes a timeout print the diverged + // state — which of root/bindings/live panes went stale is the finding. + await expect + .poll( + async () => { + const state = await readLayoutConsistency(page, tabId) + return { + hasManager: state.hasManager, + livePaneCount: state.livePaneCount, + domPaneCount: state.domPaneCount, + rootLeafCount: state.rootLeafIds.length, + boundLeafCount: state.boundLeafIds.length, + unboundRootLeafIds: state.rootLeafIds.filter( + (leafId) => !state.boundLeafIds.includes(leafId) + ), + deadPtyStillBound: deadPtyId ? state.boundPtyIds.includes(deadPtyId) : false + } + }, + { + timeout: 15_000, + message: `[${phase}] layout did not settle to ${expectedPaneCount} consistent pane(s)` + } + ) + .toEqual({ + hasManager: true, + livePaneCount: expectedPaneCount, + domPaneCount: expectedPaneCount, + rootLeafCount: expectedPaneCount, + boundLeafCount: expectedPaneCount, + unboundRootLeafIds: [], + deadPtyStillBound: false + }) +} + +async function closeLastPaneOnTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`) + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('closeLastPaneOnTab: tab has no panes') + } + manager.closePane(target.id) + }, tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is +// the observable "parked" signal (same detection as the parking spec). +async function waitForTabParked(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) +} + +async function waitForTabRemounted(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: 15_000, + message: `terminal tab ${tabId} did not remount on reveal` + }) + .toBe(true) +} + +type SplitTabSetup = { + worktreeId: string + tabId: string + splitLeafId: string + splitPtyId: string +} + +// Why: every scenario starts from the field shape — a tab whose main pane got +// a split (the "setup pane" analog) that is fully bound and settled. +async function setUpSplitTab(page: Page): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('setUpSplitTab: no active terminal tab') + } + await splitActiveTerminalPane(page, 'vertical') + const snapshot = await waitForPaneIdentitySnapshot(page, 2) + const splitPane = snapshot.panes.at(-1) + if (!splitPane?.ptyId) { + throw new Error('setUpSplitTab: split pane did not bind a PTY') + } + return { worktreeId, tabId, splitLeafId: splitPane.leafId, splitPtyId: splitPane.ptyId } +} + +test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => { + test('control: close while visible', async ({ orcaPage }) => { + const { tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible') + }) + + test('close and hide the tab in the same tick', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + throw new Error('close+hide: store/manager unavailable') + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('close+hide: no split pane') + } + manager.closePane(target.id) + // Hide tab A before any deferred post-close work can run. + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, + { tabId, worktreeId } + ) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick') + }) + + test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await closeLastPaneOnTab(orcaPage, tabId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') + }) + + test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Close as soon as the manager exists — panes may still be attaching. + await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const target = manager?.getPanes().at(-1) + if (manager && target) { + manager.closePane(target.id) + } + }, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal') + }) + + test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') + }) + + test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + // The setup-script analog: the split's shell ends on its own while the + // tab is hidden-but-mounted. + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS / 2) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId) + }) + + test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Why: the parked exit is deliberately deferred (no PaneManager to promote + // siblings) — the reveal remount owns the per-leaf teardown. This asserts + // that ownership actually resolves instead of leaving a ghost pane. + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId) + }) +}) diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts new file mode 100644 index 00000000000..2fb1165abbe --- /dev/null +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -0,0 +1,358 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: production cold-park hysteresis is 30s. The fast-park env override is +// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as +// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }, + // Why: without this switch Chromium quantizes performance.memory and only + // refreshes it every ~20 minutes, so both scenarios report the same stale + // launch-time bucket instead of a comparable heap figure. + orcaAppExtraArgs: ['--enable-precise-memory-info'] +}) + +// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit +// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS +// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the +// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden +// past hotRetainMs before the retain-count limit is even consulted. So all 8 +// park without needing 14 tabs or extra policy knobs. +const SCROLLBACK_TAB_COUNT = 8 +const SCROLLBACK_LINE_COUNT = 3000 +const PARK_SETTLE_MS = 2_000 +const HEAP_SAMPLE_COUNT = 5 +const HEAP_SAMPLE_INTERVAL_MS = 250 +// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of +// scrollback each, then waits out the parking window — well past the default +// 120s per-test budget. +const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000 + +// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes +// each xterm hold realistic narrow+wide buffer rows, so released parked-tab +// memory reflects real agent output rather than uniform filler. +function writeScrollbackFillScript(scriptPath: string, runId: string): void { + const script = [ + `const tabIndex = process.argv[2] ?? '0'`, + `const wide = '統合端末記憶計測'`, + `const emoji = ['🟢', '🟡', '🔵', '🟣']`, + `const lines = []`, + `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`, + ` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`, + ` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`, + ` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`, + `}`, + `process.stdout.write(lines.join('\\n') + '\\n')`, + `process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')` + ].join('\n') + writeFileSync(scriptPath, `${script}\n`) +} + +// Why: the spec lands ahead of the feature wiring in some merge orders. Skip +// (rather than fail) when the app under test does not expose the parking +// debug handle, mirroring terminal-hidden-view-parking.spec.ts. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + } + test.skip( + !present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so +// a missing manager is the observable signal that the tab's xterm was parked. +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise { + return page.evaluate( + (tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length, + tabIds + ) +} + +async function waitForTabsParked(page: Page, tabIds: string[]): Promise { + await expect + .poll(() => countMountedPaneManagers(page, tabIds), { + timeout: Math.max(30_000, PARKING_DELAY_MS * 10), + message: 'hidden scrollback tabs did not all park (pane managers still mounted)' + }) + .toBe(0) +} + +type ScrollbackTab = { + tabId: string + ptyId: string +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + const snapshot = await waitForPaneIdentitySnapshot(page, 1) + const ptyId = snapshot.panes[0]?.ptyId + if (snapshot.tabId !== tabId || !ptyId) { + throw new Error('createActiveTerminalTab: new tab did not bind a PTY') + } + return { tabId, ptyId } +} + +async function fillActiveTerminalWithScrollback( + page: Page, + ptyId: string, + scriptPath: string, + tabIndex: number, + runId: string +): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`) + await expect + .poll(() => getTerminalContent(page, 4_000), { + timeout: 30_000, + message: `scrollback fill marker for tab ${tabIndex} did not render` + }) + .toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`) +} + +type ScrollbackTabSetup = { + worktreeId: string + scrollbackTabs: ScrollbackTab[] +} + +// Why: each tab generates its scrollback while visible, so every xterm holds +// the full buffer before going hidden — the hidden-delivery gate never gets a +// chance to drop the output the memory comparison depends on. +async function setUpScrollbackTabs( + page: Page, + scriptPath: string, + runId: string +): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1) + const baselinePtyId = baselineSnapshot.panes[0]?.ptyId + if (!baselinePtyId) { + throw new Error('parked memory spec: baseline terminal tab did not bind a PTY') + } + + const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }] + await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId) + for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) { + const tab = await createActiveTerminalTab(page, worktreeId) + scrollbackTabs.push(tab) + await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId) + } + return { worktreeId, scrollbackTabs } +} + +type ParkedMemoryMetrics = { + heapUsedMB: number + liveTerminals: number + livePaneManagers: number +} + +// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best +// effort) and take the min of several settled samples — the min reflects +// retained heap instead of allocation noise between collections. Note xterm +// buffer rows are typed-array backing stores outside the V8 heap, so the +// liveTerminals/livePaneManagers counts are the strong release signal and the +// heap figure tracks only the on-heap share. +async function sampleParkedMemoryMetrics(page: Page): Promise { + await page.waitForTimeout(PARK_SETTLE_MS) + try { + const session = await page.context().newCDPSession(page) + await session.send('HeapProfiler.collectGarbage') + await session.detach() + } catch { + // GC over CDP is a measurement-fidelity improvement, not a gate. + } + + let minHeapBytes: number | null = null + for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) { + const heapBytes = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + return memory?.usedJSHeapSize ?? null + }) + if (heapBytes !== null) { + minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes) + } + await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS) + } + if (minHeapBytes === null) { + throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable') + } + + const liveCounts = await page.evaluate(() => ({ + liveTerminals: document.querySelectorAll('.xterm').length, + livePaneManagers: window.__paneManagers?.size ?? 0 + })) + return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts } +} + +function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string { + return [ + `panes=${SCROLLBACK_TAB_COUNT}`, + `parkedTabs=${parkedTabs}`, + `heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`, + `liveTerminals=${metrics.liveTerminals}`, + `livePaneManagers=${metrics.livePaneManagers}` + ].join(' ') +} + +test.describe('Terminal parked memory', () => { + test('releases renderer terminal memory when hidden tabs park', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + + // A fresh 9th tab hides all 8 scrollback tabs. + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabsParked( + orcaPage, + scrollbackTabs.map((tab) => tab.tabId) + ) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory', + description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length) + }) + + // Structural assertions: all 8 parked (managers gone), and the only + // live xterm/pane manager belongs to the visible tab. + for (const tab of scrollbackTabs) { + expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false) + } + const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId) + expect(visibleState.hasManager).toBe(true) + expect(visibleState.paneCount).toBeGreaterThan(0) + // Why: design invariant 5 — renderer terminal views scale with visible + // panes, so parked tabs must leave no xterm DOM behind. + expect(metrics.liveTerminals).toBe(visibleState.paneCount) + expect(metrics.livePaneManagers).toBe(1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('retains terminal views when parking is disabled', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + // Why: settings.terminalHiddenViewParking === false is the design-doc + // kill switch. updateSettings persists it through window.api.settings.set + // and updates the store slice the cold-park hook subscribes to — the same + // mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env + // wiring is needed. + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('parked memory spec: window.__store is unavailable') + } + await store.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expect + .poll( + () => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking), + { timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' } + ) + .toBe(false) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId) + + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + // Why: with parking enabled these tabs park within ~1x the collapsed + // delay (the first test proves the machinery in this app build), so + // surviving 3x the delay shows the kill switch held. + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory-disabled', + description: formatParkedMemoryAnnotation(metrics, 0) + }) + + // Structural assertions: every hidden tab keeps its pane manager and + // xterm; nothing parked even after the settle + sampling window. + for (const tab of scrollbackTabs) { + const state = await readTerminalTabViewState(orcaPage, tab.tabId) + expect(state.hasManager).toBe(true) + expect(state.paneCount).toBeGreaterThan(0) + } + expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true) + expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1) + expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts new file mode 100644 index 00000000000..6a57a4a3506 --- /dev/null +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -0,0 +1,106 @@ +/** + * Repro + recovery for the dead-push-delivery wedge (field snapshot, + * v1.4.121-rc.0, 2026-07-06): every `pty:data` push event vanishes before the + * renderer processes it while invoke IPC stays healthy — terminals go + * literally blank (bytes sent, never consumed, never ACKed) and previously + * only a renderer reload recovered. + * + * The `__terminalDeliveryWatchdog.blackhole` hook drops incoming pty:data at + * the dispatcher exactly as the field failure does (no receive count, no ACK, + * no handler). The watchdog must then confirm the wedge over invoke, write off + * the lost bytes in main, and repaint the pane from the main-owned buffer + * snapshot — all WITHOUT the push channel and WITHOUT a reload. The wedged + * output becomes visible while the blackhole is still engaged: that is the + * pull-recovery proof. + * + * Timing: the watchdog runs at 500ms ticks here, but main refuses a write-off + * until it has seen 10s of ACK silence (PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS, + * a deliberate prod constant) — so recovery lands at ~11-13s and the polls + * below allow 30s. + */ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: { + blackhole: (on: boolean) => void + configure: (config: { + intervalMs?: number + stallTicksToHeal?: number + healCooldownMs?: number + }) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } + } +} + +test.describe('terminal push-delivery loss recovery', () => { + test.afterEach(async ({ orcaPage }) => { + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + }) + + test('watchdog repaints wedged terminals from the main buffer without push delivery or reload', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: push delivery works. The $((…)) arithmetic keeps the + // asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Engage the field wedge and speed the watchdog up for CI. + await orcaPage.evaluate(() => { + const watchdog = (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog + if (!watchdog) { + throw new Error('delivery watchdog e2e hook missing — exposeStore build?') + } + watchdog.configure({ intervalMs: 500, healCooldownMs: 3_000 }) + watchdog.blackhole(true) + }) + + await execInTerminal(orcaPage, ptyId, 'echo wedged-$((100+23))') + + // The wedge repro itself: output is swallowed, pane stays stale. + await orcaPage.waitForTimeout(1_500) + expect(await getTerminalContent(orcaPage)).not.toContain('wedged-123') + + // Pull-recovery proof: while the push channel is still dead, the healed + // pane repaints from the main-owned snapshot and shows the wedged output. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('wedged-123') + const healSnapshot = await orcaPage.evaluate( + () => (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() ?? null + ) + expect(healSnapshot?.healCount ?? 0).toBeGreaterThan(0) + + // Channel restored: live output flows again with no reload in between. + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + }) +}) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 238bd4f9d26..3f4d4f17dae 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -180,7 +180,10 @@ function rawEmojiFixtureFrameTailMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1480, height: 820 }) + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + // Why: macOS hosted runners need extra room for font/column variance, while + // Windows Electron golden rendering is stable at the native-sized viewport. + await page.setViewportSize({ width: isWindows ? 1480 : 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -556,6 +559,9 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await switchToWorktree(orcaPage, secondWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(1_000) + // Why: switching back can replay hidden terminal contents immediately; + // make the viewport wide before restore so the table cannot wrap first. + await setWideRenderedTableViewport(orcaPage) await switchToWorktree(orcaPage, firstWorktreeId) // Why: activating another worktree can restore the right sidebar. This // golden is about terminal renderer restore at a deliberately wide width. diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts new file mode 100644 index 00000000000..83a924a1099 --- /dev/null +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type SleepWakeTerminalDebug = { + activeTabId: string | null + activeWorktreeId: string | null + tabs: { + id: string + ptyId?: string + generation?: number + pendingActivationSpawn?: boolean | number + }[] + ptyIdsByTabId: Record + ptyIdsByLeafIdByTabId: Record> +} + +async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + await state.shutdownWorktreeBrowsers(id) + await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, worktreeId) +} + +async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return 0 + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0) + }, worktreeId) +} + +async function readSleepWakeTerminalDebug( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return { + activeTabId: null, + activeWorktreeId: null, + tabs: [], + ptyIdsByTabId: {}, + ptyIdsByLeafIdByTabId: {} + } + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return { + activeTabId: state.activeTabId, + activeWorktreeId: state.activeWorktreeId, + tabs: tabs.map((tab) => ({ + id: tab.id, + ptyId: tab.ptyId, + generation: tab.generation, + pendingActivationSpawn: tab.pendingActivationSpawn + })), + ptyIdsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []]) + ), + ptyIdsByLeafIdByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}]) + ) + } + }, worktreeId) +} + +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +function richSleepWakePayload(runId: string): string { + const shortId = runId.slice(0, 8) + return [ + '\x1b[?2026h', + '\x1b[2J\x1b[H', + '╭────────────────────────────────────────────╮', + `│ sleep wake restore ${shortId} 😀 │`, + '├────────────┬───────────────┬───────────────┤', + '│ agent │ status │ output │', + '├────────────┼───────────────┼───────────────┤', + `│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`, + '│ opencode │ streaming │ unicode ✓ │', + '│ shell │ idle │ prompt ready │', + '╰────────────┴───────────────┴───────────────╯', + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + '\x1b[?2026l' + ].join('\r\n') +} + +function sleepWakeExpectedMarkers(runId: string): string[] { + return [ + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + 'box/table ok', + 'unicode ✓', + 'prompt ready' + ] +} + +function writeSleepWakePayloadScript(scriptPath: string, payload: string): void { + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + writeFileSync( + scriptPath, + `process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`, + 'utf8' + ) +} + +test.describe('Terminal sleep wake restore', () => { + test('restores slept terminal output and accepts fresh input after wake', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` + const freshMarker = `SLEEP_WAKE_FRESH_${runId}` + const expectedMarkers = sleepWakeExpectedMarkers(runId) + const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`) + writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId)) + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + for (const marker of expectedMarkers) { + expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true) + } + + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + for (const marker of expectedMarkers) { + expect + .soft(awakeTerminalContent.includes(marker), { + message: JSON.stringify( + { + missingMarker: marker, + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + } + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts new file mode 100644 index 00000000000..6545264fee3 --- /dev/null +++ b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts @@ -0,0 +1,147 @@ +/** + * Repro + recovery for the stuck-occlusion freeze (field snapshot, + * v1.4.124-rc.2.perf, 2026-07-06): macOS occlusion tracking wedges + * document.visibilityState at 'hidden' after display sleep and never fires + * another visibilitychange. The hidden-delivery gate then marks the pane the + * user is looking at as hidden, and main drops its renderer-bound bytes + * indefinitely (78MB dropped across 2 visible ptys in the field) — a frozen + * terminal with a perfectly healthy transport. + * + * The test emulates the wedge exactly as Chromium produces it: visibilityState + * pinned to 'hidden' with one final visibilitychange, then silence. Recovery + * must come from the staleness proof — a real keystroke while the document + * claims hidden — which unlatches the gate and repaints the missed output from + * the main-owned snapshot, WITHOUT a reload and WITHOUT any visibilitychange. + */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliverySnapshot = { + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryDroppedChars: number +} + +async function getDeliverySnapshot(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return { + hiddenDeliveryGatedPtyCount: snapshot.hiddenDeliveryGatedPtyCount, + hiddenDeliveryGatedVisiblePtyCount: snapshot.hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryDroppedChars: snapshot.hiddenDeliveryDroppedChars + } + }) +} + +test.describe('terminal stuck-occlusion recovery', () => { + test.afterEach(async ({ orcaPage }) => { + // Drop the instance shadow so the prototype getter (real state) rules + // again, and fire one genuine visibilitychange to restore tracker trust. + await orcaPage.evaluate(() => { + delete (document as { visibilityState?: string }).visibilityState + document.dispatchEvent(new Event('visibilitychange')) + }) + }) + + test('a keystroke unlatches the hidden-delivery gate wedged by stale visibilityState', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: foreground delivery works. The $((…)) arithmetic keeps + // the asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Emulate the Chromium occlusion wedge: visibilityState pins at 'hidden', + // one last visibilitychange fires, then the tracker goes silent forever. + await orcaPage.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { + get: () => 'hidden', + configurable: true + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // The visible pane's pty gets marked hidden in main — the field state: + // gate holding a pty that main's own visibility set says is visible. + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedPtyCount, { + timeout: 15_000 + }) + .toBeGreaterThan(0) + expect( + (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount + ).toBeGreaterThan(0) + + // The freeze repro: output produced now is dropped by main, not painted. + const droppedBefore = (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars + await execInTerminal(orcaPage, ptyId, 'echo occluded-$((70+8))') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars, { + timeout: 15_000 + }) + .toBeGreaterThan(droppedBefore) + expect(await getTerminalContent(orcaPage)).not.toContain('occluded-78') + + // The staleness proof: one real keystroke while the document claims + // hidden. No visibilitychange fires — recovery must ride the proof alone. + await orcaPage.keyboard.press('Shift') + + // Gate unlatches and the missed output repaints from the main-owned + // snapshot — no reload, visibilityState still reads 'hidden'. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('occluded-78') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount, { + timeout: 15_000 + }) + .toBe(0) + + // Live delivery continues under the override. + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + + // The one-paste freeze report is prod-reachable and carries the episode's + // history: the stale-visibility latch and gate transitions must be in the + // renderer breadcrumbs, and main's per-pty table must be populated. + const report = await orcaPage.evaluate(() => + ( + window as Window & { + __orcaTerminalFreezeReport?: () => Promise<{ + renderer: { breadcrumbs: { kind: string }[]; documentVisibilityProvenStale: boolean } + main: { diagnostics: { perPty: unknown[]; breadcrumbs: { kind: string }[] } } + }> + } + ).__orcaTerminalFreezeReport?.() + ) + if (!report) { + throw new Error('freeze report global missing from prod-path renderer') + } + expect(report.renderer.documentVisibilityProvenStale).toBe(true) + const rendererKinds = report.renderer.breadcrumbs.map((crumb) => crumb.kind) + expect(rendererKinds).toContain('stale-visibility-latch') + expect(rendererKinds).toContain('renderer-gate-unmark') + expect(report.main.diagnostics.perPty.length).toBeGreaterThan(0) + const mainKinds = report.main.diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(mainKinds).toContain('gate-mark') + expect(mainKinds).toContain('gate-unmark') + }) +}) diff --git a/tools/benchmarks/cpu-pressure-worker.mjs b/tools/benchmarks/cpu-pressure-worker.mjs new file mode 100644 index 00000000000..a0579be6dbd --- /dev/null +++ b/tools/benchmarks/cpu-pressure-worker.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Busy-spins one CPU core to emulate a loaded machine during latency benches + * (the "every workspace is running an agent and the whole box is hot" case). + * Spawn N of these to occupy N cores; kill to release. + * + * Usage: node cpu-pressure-worker.mjs [maxDurationMs] + * The duration failsafe (default 10 min) prevents orphaned spinners if the + * spawning bench dies without cleanup. + */ +const maxDurationMs = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 10 * 60 * 1000 +const deadline = Date.now() + maxDurationMs + +let x = 1 +while (Date.now() < deadline) { + // Hot integer loop between deadline checks; no yields, no allocation. + for (let i = 0; i < 5_000_000; i++) { + x = (x * 31 + 7) % 1000003 + } +} +process.exit(0) diff --git a/tools/benchmarks/terminal-headless-parse-bench.mjs b/tools/benchmarks/terminal-headless-parse-bench.mjs new file mode 100644 index 00000000000..694593cfb3c --- /dev/null +++ b/tools/benchmarks/terminal-headless-parse-bench.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Decomposes cross-terminal pipeline results: feeds the same fixtures from + * terminal-pipeline-bench through a bare @xterm/headless Terminal — no Orca + * layers, no IPC, no rendering — to locate where throughput is lost. + * + * If headless xterm parses a fixture near the plain-text rate, the pipeline + * gap for that fixture lives in Orca's layers (delivery, side-effect + * scanning, renderer paint). If headless collapses too, the cost is intrinsic + * to xterm.js's parser/buffer for that byte pattern. + * + * Usage: + * node tools/benchmarks/terminal-headless-parse-bench.mjs + * [--size-mb 10] [--cols 114] [--rows 85] [--scrollback 5000] + */ +import { performance } from 'node:perf_hooks' +import xterm from '@xterm/headless' +import { buildFixture } from './terminal-pipeline-bench.mjs' + +const { Terminal } = xterm +const CHUNK = 64 * 1024 + +function arg(name, fallback) { + const i = process.argv.indexOf(name) + return i === -1 ? fallback : Number(process.argv[i + 1]) +} + +const sizeMb = arg('--size-mb', 10) +const cols = arg('--cols', 114) +const rows = arg('--rows', 85) +const scrollback = arg('--scrollback', 5000) +const targetBytes = Math.floor(sizeMb * 1024 * 1024) + +function writeAll(term, data) { + return new Promise((resolve) => { + let offset = 0 + const next = () => { + if (offset >= data.length) { + resolve() + return + } + const chunk = data.slice(offset, offset + CHUNK) + offset += CHUNK + // write callback fires after the chunk is parsed — same "fully parsed" + // fence semantics as the DSR fence in the pipeline bench. + term.write(chunk, next) + } + next() + }) +} + +const FIXTURES = ['ascii-log', 'cjk-emoji', 'agent-tui', 'styles-stress'] + +console.log( + `headless xterm ${cols}x${rows} scrollback=${scrollback}, ${sizeMb}MB per fixture (parse-only, no render)` +) +for (const name of FIXTURES) { + const fixture = buildFixture(name, targetBytes, cols, rows) + const bytes = Buffer.byteLength(fixture, 'utf8') + const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + // Warmup primes JIT so the first fixture isn't penalized. + await writeAll(term, fixture.slice(0, 256 * 1024)) + const start = performance.now() + await writeAll(term, fixture) + const ms = performance.now() - start + console.log( + `${name.padEnd(15)} ${(bytes / 1024 / 1024 / (ms / 1000)).toFixed(1).padStart(7)} MB/s (${ms.toFixed(0)}ms)` + ) + term.dispose() +} diff --git a/tools/benchmarks/terminal-pipeline-bench.mjs b/tools/benchmarks/terminal-pipeline-bench.mjs new file mode 100644 index 00000000000..9d013dcc3ca --- /dev/null +++ b/tools/benchmarks/terminal-pipeline-bench.mjs @@ -0,0 +1,569 @@ +#!/usr/bin/env node +/** + * Cross-terminal pipeline benchmark. Run INSIDE the terminal under test + * (Orca pane, iTerm2, Ghostty, Terminal.app, VS Code, ...) — it measures the + * full byte path of whatever terminal hosts it: PTY -> (daemon/ptyHost -> + * IPC ->) parser -> response. + * + * Metrics per run: + * 1. dsr-idle — DSR (ESC[6n) round-trip latency at rest. The reply is + * produced only after the terminal's parser reaches the + * query, so this tracks the input/echo pipeline without + * needing OS-level keystroke injection. + * 2. throughput — wall time to stream each fixture, ended by a DSR + * fence. The fence matters: xterm.js-class terminals + * ingest at wire speed into an internal queue and parse + * later, so socket drain alone would flatter them. + * 3. dsr-under-load — DSR latency sampled while a paced (default 1 MB/s) + * agent-TUI stream plays. This is the "typing while an + * agent floods output" complaint, quantified. + * + * Usage (run in EACH terminal being compared, same machine, on AC power): + * node tools/benchmarks/terminal-pipeline-bench.mjs --label m2max-2026-07-02 + * [--size-mb 10] [--iterations 5] [--dsr-count 200] [--skip-load] + * [--fixtures ascii-log,cjk-emoji,agent-tui,styles-stress] + * + * Aggregate results from all terminals into one comparison table: + * node tools/benchmarks/terminal-pipeline-bench.mjs report [--label ] + * + * Protocol notes: keep hands off the keyboard during a run (stdin is parsed + * for DSR replies), use a comparable window size everywhere, avoid tmux/screen + * (they proxy the queries and would be the thing measured). `styles-stress` is + * deliberately pathological (every cell restyled); read it as a ceiling probe, + * not a realistic workload. Complementary manual metric: Typometer for true + * keypress->pixel latency — this probe stops at the parser reply. + * Results: tools/benchmarks/results/terminal-pipeline-
    - + {canOpenAiVaultSessionLogInOrca(session) ? ( + + ) : null}
    ) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index ce9416ac73a..c9fcadece94 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -21,7 +21,10 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' -import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' +import { + canOpenAiVaultSessionLogInOrca, + canUseLocalAiVaultSessionPathActions +} from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -298,6 +301,10 @@ function AiVaultVirtualRow({ const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' const canOpenLocalSessionPaths = row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) + // Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode) + // identities that have no single file to open, while Reveal/CWD stay on the + // existing local-path gate. + const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session) return (
    onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined} onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} onOpenCwd={ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts new file mode 100644 index 00000000000..bde07f87540 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastMock, toastErrorMock, getStateMock } = vi.hoisted(() => ({ + toastMock: vi.fn(), + toastErrorMock: vi.fn(), + getStateMock: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: Object.assign(toastMock, { error: toastErrorMock }) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/i18n/i18n', () => ({ + // Return the English fallback so assertions can match user-facing copy. + translate: (_key: string, fallback: string) => fallback +})) + +import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' + +const LOG_PATH = '/home/user/.claude/sessions/log.jsonl' + +type FakeState = { + activeWorktreeId: string | null + activeGroupIdByWorktree: Record + openFiles: Record[] + worktreesByRepo: Record + folderWorkspaces: { id: string }[] + openFile: ReturnType +} + +function makeState(overrides: Partial = {}): FakeState { + return { + activeWorktreeId: 'wt-1', + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, + openFiles: [], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] }, + folderWorkspaces: [], + openFile: vi.fn(), + ...overrides + } +} + +let authorizeMock: ReturnType + +beforeEach(() => { + authorizeMock = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: authorizeMock } } }) +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('openAiVaultSessionLogInOrca', () => { + it('authorizes the exact path and opens a permanent read-only local tab', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(authorizeMock).toHaveBeenCalledWith({ targetPath: LOG_PATH }) + expect(state.openFile).toHaveBeenCalledTimes(1) + const [file, options] = state.openFile.mock.calls[0] + expect(file).toEqual({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'jsonl', + mode: 'edit', + readOnly: true, + liveTail: true + }) + expect(options).toEqual({ + preview: false, + forceContentReload: true, + suppressActiveRuntimeFallback: true, + targetGroupId: 'group-1' + }) + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('withholds blank, remote, and synthetic paths without authorizing', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: ' ', executionHostId: 'local' }) + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'ssh:dev-box' }) + await openAiVaultSessionLogInOrca({ + filePath: '/home/user/.opencode/db.sqlite#sess_1', + executionHostId: 'local' + }) + + expect(authorizeMock).not.toHaveBeenCalled() + expect(state.openFile).not.toHaveBeenCalled() + }) + + it('toasts and creates no tab when authorization rejects', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + authorizeMock.mockRejectedValue(new Error('denied')) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledWith("Couldn't open log — path not authorized.") + }) + + it('toasts and creates no tab when the workspace vanishes after authorization', async () => { + const state = makeState() + const stateAfter = makeState({ worktreesByRepo: {}, folderWorkspaces: [] }) + getStateMock.mockReturnValueOnce(state).mockReturnValue(stateAfter) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).not.toHaveBeenCalled() + expect(stateAfter.openFile).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledWith( + "Couldn't open log — workspace is no longer available." + ) + }) + + it('activates and shows the already-editable toast for an existing writable tab', async () => { + const state = makeState({ + openFiles: [ + { + filePath: LOG_PATH, + mode: 'edit', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + readOnly: undefined + } + ] + }) + getStateMock.mockReturnValue(state) + + await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + + expect(state.openFile).toHaveBeenCalledTimes(1) + expect(toastMock).toHaveBeenCalledWith('Log is already open for editing.') + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('shares one in-flight open for concurrent clicks of the same path', async () => { + const state = makeState() + getStateMock.mockReturnValue(state) + let resolveAuth: (() => void) | undefined + authorizeMock.mockImplementation( + () => + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + + const first = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + const second = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) + resolveAuth?.() + await Promise.all([first, second]) + + expect(authorizeMock).toHaveBeenCalledTimes(1) + expect(state.openFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts new file mode 100644 index 00000000000..2f4b6ff495c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -0,0 +1,151 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { detectLanguage } from '@/lib/language-detect' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { translate } from '@/i18n/i18n' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' + +type AiVaultLogSession = Pick + +// Why: rapid double-clicks of View Log during the authorize await must share one +// in-flight open (and toast-once on failure) so a slow FS grant can't spawn +// duplicate tabs or spam error toasts. Keyed by the exact requested path. +const inFlightOpenPaths = new Set() + +function worktreeStillExists(state: AppState, worktreeId: string): boolean { + if (findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)) { + return true + } + // Why: the AI Vault panel can be active inside a folder workspace, whose id is + // keyed differently from repo worktrees. + return (state.folderWorkspaces ?? []).some( + (workspace) => folderWorkspaceKey(workspace.id) === worktreeId + ) +} + +function focusEditorContent(): void { + // Why: land keyboard focus in the editor so Find/selection/copy work + // immediately. Double rAF lets the tab mount and Monaco attach its textarea + // before we focus it (mirrors the modal return-focus surface selector). + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return + } + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const textarea = document.querySelector('.monaco-editor textarea') + textarea?.focus() + }) + }) +} + +/** + * Open a local AI Vault session log inside Orca as a permanent, read-only editor + * tab (or activate an existing tab without reducing its authority). Reuses + * Orca's external-file authorize + `openFile` pipeline; it never grants write + * capability by itself and never redirects the open to a remote host. + */ +export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): Promise { + const filePath = session.filePath?.trim() + // Defensive: UI availability should already withhold blank/remote/synthetic + // paths. Bail silently rather than toast — there is no user-actionable error. + if (!filePath || !canOpenAiVaultSessionLogInOrca(session)) { + return + } + if (inFlightOpenPaths.has(filePath)) { + return + } + inFlightOpenPaths.add(filePath) + try { + const state = useAppStore.getState() + // Snapshot the invoking workspace/group before the authorization await so a + // delayed grant can't retarget the tab into a workspace the user moved to. + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." + ) + ) + return + } + const targetGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? undefined + // Why: an already-open *writable* tab must keep its edit authority — View Log + // only activates it and notifies. Local ownership only (runtimeEnvironmentId + // null) matches the tab this action would create/activate. + const existingWritableTab = state.openFiles.find( + (file) => + file.filePath === filePath && + file.mode === 'edit' && + file.worktreeId === worktreeId && + (file.runtimeEnvironmentId ?? null) === null && + file.readOnly !== true + ) + + try { + // The exact scanned path is the authorization oracle; the user click is the + // trust gesture. Reuses Orca's existing external R/W open grant. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + } catch { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.notAuthorized', + "Couldn't open log — path not authorized." + ) + ) + return + } + + const stateAfterAuth = useAppStore.getState() + if (!worktreeStillExists(stateAfterAuth, worktreeId)) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." + ) + ) + return + } + + stateAfterAuth.openFile( + { + filePath, + // Why: keep relativePath === filePath so the external-file contract reads + // the exact authorized path, not a worktree-relative reinterpretation. + relativePath: filePath, + worktreeId, + // Why: the path was discovered on the client-local host — pin local + // ownership so an active runtime can't reinterpret it as a remote path. + runtimeEnvironmentId: null, + language: detectLanguage(filePath), + mode: 'edit', + readOnly: true, + liveTail: true + }, + { + preview: false, + // Why: a repeated View Log refreshes a non-dirty tab; the store skips the + // reload nonce for a dirty writable buffer (no buffer replacement). + forceContentReload: true, + suppressActiveRuntimeFallback: true, + targetGroupId + } + ) + + if (existingWritableTab) { + toast( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.alreadyEditable', + 'Log is already open for editing.' + ) + ) + } + + focusEditorContent() + } finally { + inFlightOpenPaths.delete(filePath) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts index 93fee6c94e9..f546287cf98 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' +import { + canOpenAiVaultSessionLogInOrca, + canUseLocalAiVaultSessionPathActions, + isSyntheticAiVaultSessionPath +} from './ai-vault-session-path-actions' describe('canUseLocalAiVaultSessionPathActions', () => { it('allows OS path actions for local session history', () => { @@ -12,3 +16,43 @@ describe('canUseLocalAiVaultSessionPathActions', () => { expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) }) }) + +describe('isSyntheticAiVaultSessionPath', () => { + it('treats OpenCode `#` identities as synthetic', () => { + expect(isSyntheticAiVaultSessionPath('/home/user/.opencode/db.sqlite#sess_123')).toBe(true) + }) + + it('treats ordinary JSONL/JSON transcript paths as real', () => { + expect(isSyntheticAiVaultSessionPath('/home/user/.claude/sessions/log.jsonl')).toBe(false) + expect(isSyntheticAiVaultSessionPath('C:\\Users\\a\\.codex\\log.json')).toBe(false) + }) +}) + +describe('canOpenAiVaultSessionLogInOrca', () => { + it('allows a local, single-file, non-synthetic path', () => { + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/home/user/.claude/sessions/log.jsonl', + executionHostId: 'local' + }) + ).toBe(true) + }) + + it('withholds blank, remote, and synthetic identities', () => { + expect(canOpenAiVaultSessionLogInOrca({ filePath: ' ', executionHostId: 'local' })).toBe( + false + ) + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/remote/.claude/log.jsonl', + executionHostId: 'ssh:dev-box' + }) + ).toBe(false) + expect( + canOpenAiVaultSessionLogInOrca({ + filePath: '/home/user/.opencode/db.sqlite#sess_1', + executionHostId: 'local' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index f723bbb1959..5bbbdcf2d1d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -3,6 +3,7 @@ import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -11,3 +12,30 @@ export function canUseLocalAiVaultSessionPathActions( // SSH session history exposes paths that exist on the remote host instead. return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID } + +export function isSyntheticAiVaultSessionPath(filePath: string): boolean { + // Why: newer OpenCode sessions use a synthetic `#` + // scanner identity backed by SQLite — not a real filesystem path. A '#' + // session marker never appears in a genuine local transcript path, so it is + // a reliable v1 signal that there is no single file to open in Orca. + return filePath.includes('#') +} + +/** + * Whether AI Vault `View Log` / `Open Log` can open this session's log inside + * Orca as a read-only tab: a non-blank, local, single-file (non-synthetic) + * path. Remote/runtime and synthetic identities are withheld until AI Vault has + * a provider-owned log-resource contract. + */ +export function canOpenAiVaultSessionLogInOrca( + session: Pick +): boolean { + const filePath = session.filePath?.trim() + if (!filePath) { + return false + } + if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { + return false + } + return !isSyntheticAiVaultSessionPath(filePath) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 2ce15236f40..13efe982db6 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -109,8 +109,10 @@ export default function EditorFileTab({ // user's intent. This flag suppresses the trailing blur-commit. const renameCancelledRef = useRef(false) // Only on-disk edit tabs are renameable. Diff, conflict-review, and - // combined/virtual views don't point at a single concrete file we can safely rename. - const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict + // combined/virtual views don't point at a single concrete file we can safely + // rename. Read-only tabs (AI Vault View Log) also stay unrenameable — rename + // would rewrite the agent-owned artifact's backing path. + const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict && !file.readOnly const openRenameInput = (): void => { if (!canRename) { diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index a223a86453e..e029d4535ec 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -37,4 +37,15 @@ describe('detectLanguage', () => { expect(detectLanguage('rtl/defs.vh')).toBe('verilog') expect(detectLanguage('C:\\rtl\\TOP.SV')).toBe('systemverilog') }) + + it('maps .jsonl files to the dedicated jsonl language id (case-insensitive)', () => { + expect(detectLanguage('/home/user/.claude/sessions/transcript.jsonl')).toBe('jsonl') + expect(detectLanguage('C:\\Users\\alice\\.codex\\LOG.JSONL')).toBe('jsonl') + }) + + it('keeps .json/.jsonc on the built-in json language and unknown on plaintext', () => { + expect(detectLanguage('config/settings.json')).toBe('json') + expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') + expect(detectLanguage('notes/scratch.unknownext')).toBe('plaintext') + }) }) diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index 857750cd308..33895c114d1 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -20,6 +20,10 @@ const EXT_TO_LANGUAGE: Record = { '.cjs': 'javascript', '.json': 'json', '.jsonc': 'json', + // Why: JSONL is one JSON value per line; a dedicated 'jsonl' language gives + // JSON-style color without attaching JSON whole-document diagnostics that + // would flag every record after line one as trailing content. + '.jsonl': 'jsonl', '.ipynb': 'notebook', '.md': 'markdown', '.mdx': 'markdown', diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts new file mode 100644 index 00000000000..8e195a46926 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { + JSONL_LANGUAGE_ID, + jsonlLanguageConfiguration, + jsonlMonarchLanguage, + registerJsonlLanguage +} from './register-jsonl' + +function createMonacoMock(existingLanguageIds: string[] = []) { + return { + languages: { + getLanguages: vi.fn(() => existingLanguageIds.map((id) => ({ id }))), + register: vi.fn(), + setLanguageConfiguration: vi.fn(), + setMonarchTokensProvider: vi.fn() + } + } +} + +describe('registerJsonlLanguage', () => { + it('registers the jsonl language with a Monarch tokenizer and .jsonl extension', () => { + const monaco = createMonacoMock() + + registerJsonlLanguage(monaco as never) + + expect(monaco.languages.register).toHaveBeenCalledWith( + expect.objectContaining({ id: JSONL_LANGUAGE_ID, extensions: ['.jsonl'] }) + ) + expect(monaco.languages.setLanguageConfiguration).toHaveBeenCalledWith( + JSONL_LANGUAGE_ID, + jsonlLanguageConfiguration + ) + expect(monaco.languages.setMonarchTokensProvider).toHaveBeenCalledWith( + JSONL_LANGUAGE_ID, + jsonlMonarchLanguage + ) + }) + + it('does not attach the JSON language service / diagnostics', () => { + // Why: whole-document JSON validation would flag every record after line one + // as trailing content. A Monarch tokens provider is presentation-only. + expect(jsonlMonarchLanguage.tokenizer).toBeDefined() + expect('json' in jsonlMonarchLanguage).toBe(false) + }) + + it('registers once and is idempotent when the language already exists', () => { + const monaco = createMonacoMock([JSONL_LANGUAGE_ID]) + + registerJsonlLanguage(monaco as never) + + expect(monaco.languages.register).not.toHaveBeenCalled() + expect(monaco.languages.setMonarchTokensProvider).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-jsonl.ts b/src/renderer/src/lib/monaco-languages/register-jsonl.ts new file mode 100644 index 00000000000..fbe0bb4dbac --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-jsonl.ts @@ -0,0 +1,71 @@ +import type * as Monaco from 'monaco-editor' + +type MonacoModule = typeof Monaco + +export const JSONL_LANGUAGE_ID = 'jsonl' + +export const jsonlLanguageConfiguration: Monaco.languages.LanguageConfiguration = { + brackets: [ + ['{', '}'], + ['[', ']'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '"', close: '"' } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '"', close: '"' } + ] +} + +// Why: JSONL is one JSON value per line, so we reuse JSON-style Monarch tokens +// for color but must NOT attach Monaco's JSON language service — whole-document +// validation would flag every record after line one as unexpected trailing +// content. This provider is presentation-only: no schema/record validation. +export const jsonlMonarchLanguage: Monaco.languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.jsonl', + + tokenizer: { + root: [ + { include: '@whitespace' }, + // Property key vs string value are both quoted; color keys distinctly. + [/"(?:[^"\\]|\\.)*"(?=\s*:)/, 'type.identifier'], + [/"/, 'string', '@string'], + [/[{}[\]]/, '@brackets'], + [/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/, 'number'], + [/\b(?:true|false)\b/, 'keyword'], + [/\bnull\b/, 'keyword'], + [/[:,]/, 'delimiter'] + ], + + whitespace: [[/[ \t\r\n]+/, 'white']], + + string: [ + [/[^"\\]+/, 'string'], + [/\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4})/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'] + ] + } +} + +export function registerJsonlLanguage(monaco: MonacoModule): void { + const languageAlreadyRegistered = monaco.languages + .getLanguages() + .some((language) => language.id === JSONL_LANGUAGE_ID) + if (languageAlreadyRegistered) { + return + } + + monaco.languages.register({ + id: JSONL_LANGUAGE_ID, + extensions: ['.jsonl'], + aliases: ['JSON Lines', 'jsonl', 'ndjson'] + }) + monaco.languages.setLanguageConfiguration(JSONL_LANGUAGE_ID, jsonlLanguageConfiguration) + monaco.languages.setMonarchTokensProvider(JSONL_LANGUAGE_ID, jsonlMonarchLanguage) +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 8a955e99d1c..523ecf8e77b 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -8,6 +8,7 @@ import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { registerAstroLanguage } from './monaco-languages/register-astro' +import { registerJsonlLanguage } from './monaco-languages/register-jsonl' import { registerNimLanguage } from './monaco-languages/register-nim' import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' @@ -77,6 +78,7 @@ registerVueLanguage(monaco) registerSvelteLanguage(monaco) registerAstroLanguage(monaco) registerNimLanguage(monaco) +registerJsonlLanguage(monaco) installMonacoDelayerCancellationGuard() installMonacoDiffEditorDisposalGuard(monaco) installMonacoPeekReferencesPreviewOptions() diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 99dd4ec5970..ad12ccec39c 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -123,4 +123,63 @@ describe('workspace session editor drafts', () => { }) ]) }) + + it('persists readOnly:true and never emits a dirty draft for read-only tabs', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/home/user/.claude/log.jsonl', + filePath: '/home/user/.claude/log.jsonl', + relativePath: '/home/user/.claude/log.jsonl', + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + // Why: even if isDirty is somehow set, a read-only tab must not + // persist a draft that a restore could write back to disk. + isDirty: true, + readOnly: true, + liveTail: true + } as never + ], + editorDrafts: { + '/home/user/.claude/log.jsonl': 'stray draft that must not persist' + } + }) + ) + + const persisted = payload.openFilesByWorktree?.['wt-1']?.[0] + expect(persisted).toEqual( + expect.objectContaining({ + filePath: '/home/user/.claude/log.jsonl', + readOnly: true, + liveTail: true + }) + ) + expect(persisted).toEqual( + expect.not.objectContaining({ dirtyDraftContent: expect.any(String) }) + ) + }) + + it('omits readOnly for ordinary writable tabs (writable is the default)', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/tmp/clean.md', + filePath: '/tmp/clean.md', + relativePath: 'clean.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false + } as never + ] + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']?.[0]).toEqual( + expect.not.objectContaining({ readOnly: expect.anything() }) + ) + }) }) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 4970840d5fb..21a11eb0eec 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -127,7 +127,10 @@ export function buildEditorSessionData( const editFileIdsByWorktree: Record> = {} for (const f of editFiles) { const arr = byWorktree[f.worktreeId] ?? (byWorktree[f.worktreeId] = []) - const dirtyDraftContent = f.isDirty ? editorDrafts[f.id] : undefined + // Why: read-only tabs never persist a dirty draft even if isDirty is + // somehow set — restoring a draft would reintroduce writable/hot-exit state + // for an agent-owned transcript. + const dirtyDraftContent = f.isDirty && f.readOnly !== true ? editorDrafts[f.id] : undefined arr.push({ filePath: f.filePath, relativePath: f.relativePath, @@ -135,6 +138,10 @@ export function buildEditorSessionData( language: f.language, isPreview: f.isPreview || undefined, runtimeEnvironmentId: f.runtimeEnvironmentId, + // Why: persist read-only only when true so pre-existing writable sessions + // stay writable on restore (absence is the writable default). + ...(f.readOnly === true ? { readOnly: true } : {}), + ...(f.readOnly === true && f.liveTail === true ? { liveTail: true } : {}), ...(dirtyDraftContent !== undefined ? { dirtyDraftContent } : {}), // Why: the edit baseline travels with the dirty draft so a restore can // re-derive a changed-on-disk conflict before autosave may overwrite an diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index d8738600d69..3265ad343b0 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -37,6 +37,7 @@ export type RuntimeReadableFileContent = { isBinary: boolean isImage?: boolean mimeType?: string + fileIdentity?: string } export type RuntimeFileReadArgs = { @@ -45,6 +46,7 @@ export type RuntimeFileReadArgs = { relativePath?: string worktreeId?: string connectionId?: string + includeLocalLogMetadata?: boolean } export type RuntimeFileOperationArgs = { @@ -147,14 +149,15 @@ export async function readRuntimeFileContent({ filePath, relativePath, worktreeId, - connectionId + connectionId, + includeLocalLogMetadata }: RuntimeFileReadArgs): Promise { const target = getActiveRuntimeTarget(settings) if (target.kind !== 'environment') { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!worktreeId) { - return window.api.fs.readFile({ filePath, connectionId }) + return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) } if (!canReadRelativeRuntimeFile(relativePath)) { throw new Error('Remote file is outside the owning runtime worktree') diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 760754294ef..cbdd62a728f 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4548,3 +4548,126 @@ describe('closeFile host mirroring', () => { expect(store.getState().openFiles).toHaveLength(0) }) }) + +describe('read-only editor tabs (AI Vault View Log)', () => { + const LOG_PATH = '/home/user/.claude/sessions/log.jsonl' + + const openReadOnlyLog = (store: StoreApi): void => + store.getState().openFile( + { + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + readOnly: true, + liveTail: true, + runtimeEnvironmentId: null + }, + { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } + ) + + it('creates a permanent read-only edit tab', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + filePath: LOG_PATH, + mode: 'edit', + readOnly: true, + liveTail: true, + isPreview: undefined, + runtimeEnvironmentId: null + }) + ) + }) + + it('bumps the reload nonce on repeated View Log of a clean read-only tab', () => { + const store = createEditorStore() + openReadOnlyLog(store) + expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBeUndefined() + + openReadOnlyLog(store) + expect(store.getState().openFiles[0]?.fileContentReloadNonce).toBe(1) + }) + + it('keeps read-only sticky when the same path is opened writable (no silent upgrade)', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + store.getState().openFile({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + runtimeEnvironmentId: null + }) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.readOnly).toBe(true) + }) + + it('never flips an existing writable tab to read-only on View Log', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + mode: 'edit', + runtimeEnvironmentId: null + }) + + openReadOnlyLog(store) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.readOnly).toBeUndefined() + }) + + it('markFileDirty and setEditorDraft hard no-op for read-only tabs', () => { + const store = createEditorStore() + openReadOnlyLog(store) + + store.getState().markFileDirty(LOG_PATH, true) + store.getState().setEditorDraft(LOG_PATH, 'stray edit') + + expect(store.getState().openFiles[0]?.isDirty).toBe(false) + expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() + }) + + it('hydrates a persisted read-only tab clean and ignores any persisted dirty draft', () => { + const store = createEditorStore() + store.setState({ + worktreesByRepo: { 'repo-1': [{ id: 'wt-1' }] }, + folderWorkspaces: [] + } as never) + + store.getState().hydrateEditorSession({ + openFilesByWorktree: { + 'wt-1': [ + { + filePath: LOG_PATH, + relativePath: LOG_PATH, + worktreeId: 'wt-1', + language: 'jsonl', + readOnly: true, + liveTail: true, + // Why: a corrupt/legacy session could carry a draft; hydrate must + // hard-strip it so the restored log can never come back writable. + dirtyDraftContent: 'should be ignored', + lastKnownDiskSignature: 'sig' + } + ] + } + } as never) + + const restored = store.getState().openFiles.find((f) => f.filePath === LOG_PATH) + expect(restored).toEqual( + expect.objectContaining({ readOnly: true, liveTail: true, isDirty: false, mode: 'edit' }) + ) + expect(restored?.pendingDiskBaselineVerification).toBeUndefined() + expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 2a63053f3e1..07de857fa33 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -282,6 +282,14 @@ export type OpenFile = { * tabs may be culled when they vanish from a later host snapshot; locally * opened tabs have no host counterpart and must survive snapshot syncs. */ mirroredFromRuntimeSession?: boolean + /** Why: orthogonal to `mode` — a `mode: 'edit'` tab that must never accept + * edits, dirty state, autosave, format, or rename. Used by AI Vault View Log + * so an agent-owned transcript cannot be mutated through editor write paths. + * Persisted only when true; absence is the writable default. */ + readOnly?: boolean + /** Why: live tail is explicit and only meaningful for a read-only local log; + * ordinary editor tabs and read-only snapshots keep their existing behavior. */ + liveTail?: boolean mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details' } @@ -1337,9 +1345,16 @@ function getWorktreeConnectionId(state: AppState, worktreeId: string): string | export const createEditorSlice: StateCreator = (set, get) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => - set((s) => ({ - editorDrafts: { ...s.editorDrafts, [fileId]: content } - })), + set((s) => { + // Why: read-only tabs (e.g. AI Vault View Log) must never accumulate an + // editor draft — a draft is the seed of dirty state, autosave, and a + // hot-exit restore that could write over an agent-owned transcript. + const file = s.openFiles.find((f) => f.id === fileId) + if (file?.readOnly === true) { + return s + } + return { editorDrafts: { ...s.editorDrafts, [fileId]: content } } + }), clearEditorDraft: (fileId) => set((s) => { if (!(fileId in s.editorDrafts)) { @@ -1681,6 +1696,10 @@ export const createEditorSlice: StateCreator = (s if (!needsExistingUpdate) { return activeResult } + // Why: `readOnly` is intentionally NOT in this override map. It is + // sticky: an existing tab keeps its own authority (`...f`). View Log + // never flips a writable tab to read-only, and an ordinary open never + // silently upgrades a read-only View Log tab to writable. return { openFiles: s.openFiles.map((f) => f.id === id @@ -2442,6 +2461,11 @@ export const createEditorSlice: StateCreator = (s if (!file) { return s } + // Why: read-only tabs can never become dirty; a mutation path that reached + // here (stray change/save callback) must hard no-op the integrity invariant. + if (file.readOnly === true) { + return s + } const needsPreviewClear = dirty && file.isPreview if (file.isDirty === dirty && !needsPreviewClear) { return s @@ -4337,7 +4361,11 @@ export const createEditorSlice: StateCreator = (s worktreeId, runtimeEnvironmentId: pf.runtimeEnvironmentId }) - if (pf.dirtyDraftContent !== undefined) { + // Why: read-only tabs (AI Vault View Log) must restore clean. Ignore + // any persisted dirty draft / baseline so a restored agent log can + // never come back writable or as a hot-exit draft to be saved. + const isReadOnly = pf.readOnly === true + if (!isReadOnly && pf.dirtyDraftContent !== undefined) { editorDrafts[id] = pf.dirtyDraftContent } openFiles.push({ @@ -4349,16 +4377,20 @@ export const createEditorSlice: StateCreator = (s // Re-detect on hydrate so newly-supported extensions like .ipynb // stop reopening as raw JSON/plain text after the upgrade. language: detectLanguage(pf.relativePath || pf.filePath), - isDirty: pf.dirtyDraftContent !== undefined, + isDirty: !isReadOnly && pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, - lastKnownDiskSignature: pf.lastKnownDiskSignature, + ...(isReadOnly ? { readOnly: true } : {}), + ...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}), + lastKnownDiskSignature: isReadOnly ? undefined : pf.lastKnownDiskSignature, // Why: hard-suspends autosave until the restored-tab conflict scan // verifies disk against the baseline — an async race would let a // slow remote read lose to the autosave timer and clobber an // offline agent write. pendingDiskBaselineVerification: - pf.dirtyDraftContent !== undefined && pf.lastKnownDiskSignature !== undefined + !isReadOnly && + pf.dirtyDraftContent !== undefined && + pf.lastKnownDiskSignature !== undefined ? true : undefined, mode: 'edit' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e477f5a00d0..321940a15c8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1478,6 +1478,14 @@ function createFileApi(): NonNullable['fs']> { relativePath: file.relativePath }) }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, downloadFile: async () => { throw new Error('Remote file download is unavailable in paired web clients.') }, diff --git a/src/shared/local-log-tail-types.ts b/src/shared/local-log-tail-types.ts new file mode 100644 index 00000000000..b589ff96690 --- /dev/null +++ b/src/shared/local-log-tail-types.ts @@ -0,0 +1,26 @@ +export const LOCAL_LOG_TAIL_CHUNK_BYTES = 256 * 1024 + +export type LocalLogTailReadArgs = { + filePath: string + fromByteOffset: number + expectedIdentity?: string +} + +export type LocalLogTailReadResult = { + contentBase64: string + nextByteOffset: number + fileSize: number + fileIdentity: string + hasMore: boolean + reset: boolean +} + +export type LocalLogTailWatchArgs = { + filePath: string + subscriptionId: string +} + +export type LocalLogTailChangedPayload = { + subscriptionId: string + eventType: 'change' | 'rename' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index f71f0601205..6079fa4fe9b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1027,6 +1027,11 @@ export type PersistedOpenFile = { /** Signature of the disk content the dirty draft is based on; lets restore * re-derive a changed-on-disk conflict from ground truth. */ lastKnownDiskSignature?: string + /** Why: a read-only tab (AI Vault View Log) must survive restart still + * read-only; persisted only when true so old sessions stay writable. */ + readOnly?: boolean + /** Opt-in streaming append for a read-only local log tab. */ + liveTail?: boolean } export type WorkspaceSessionState = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 22657617c10..ab5d354e017 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -162,7 +162,9 @@ const persistedOpenFileSchema = z.object({ isPreview: z.boolean().optional(), runtimeEnvironmentId: z.string().nullable().optional(), dirtyDraftContent: z.string().optional(), - lastKnownDiskSignature: z.string().optional() + lastKnownDiskSignature: z.string().optional(), + readOnly: z.boolean().optional(), + liveTail: z.boolean().optional() }) // ─── Browser ──────────────────────────────────────────────────────── From 4df945753028a4525c3468edc6c1eac89c66b1f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:24:27 -0700 Subject: [PATCH 83/95] Fix delayed terminal color-scheme replies leaking into fish (#8206) * fix(terminal): send mode 2031 replies immediately * docs(terminal): name mode 2031 color replies accurately --- .../terminal-pane/terminal-appearance.test.ts | 29 +++---- .../terminal-pane/terminal-appearance.ts | 31 +------- .../terminal-mode-2031-replies.test.ts | 68 +++++++++++++++++ .../terminal-mode-2031-replies.ts | 75 +++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 49 +++++------- 5 files changed, 177 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index b7e088ff4aa..d0c060122af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -6,10 +6,10 @@ import { applyTerminalAppearance, hexToRgba, installMode2031Handlers, - maybePushMode2031Flip, - mode2031SequenceFor, publishTerminalViewAttributesAtAppStart } from './terminal-appearance' +import { maybePushMode2031Flip } from './terminal-mode-2031-replies' +import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' @@ -17,12 +17,14 @@ import type { TerminalViewAttributes } from '../../../../shared/terminal-view-at function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean sendInput: ReturnType boolean>> + sendInputImmediate: ReturnType boolean>> } { const connected = overrides?.connected ?? true const sendOk = overrides?.sendOk ?? true return { isConnected: () => connected, - sendInput: vi.fn<(data: string) => boolean>(() => sendOk) + sendInput: vi.fn<(data: string) => boolean>(() => sendOk), + sendInputImmediate: vi.fn<(data: string) => boolean>(() => sendOk) } } @@ -42,7 +44,7 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() expect(last.has(1)).toBe(false) }) @@ -54,8 +56,9 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(true) - expect(transport.sendInput).toHaveBeenCalledTimes(1) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n') + expect(transport.sendInput).not.toHaveBeenCalled() expect(last.get(1)).toBe('dark') }) @@ -70,7 +73,7 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'dark', transport, subs, last) maybePushMode2031Flip(1, 'dark', transport, subs, last) - expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) expect(last.get(1)).toBe('dark') }) @@ -83,7 +86,7 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'light', transport, subs, last) maybePushMode2031Flip(1, 'dark', transport, subs, last) - expect(transport.sendInput.mock.calls.map((c) => c[0])).toEqual([ + expect(transport.sendInputImmediate.mock.calls.map((c) => c[0])).toEqual([ '\x1b[?997;1n', '\x1b[?997;2n', '\x1b[?997;1n' @@ -99,11 +102,11 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() expect(last.has(1)).toBe(false) }) - it('leaves last-mode untouched when sendInput reports failure', () => { + it('leaves last-mode untouched when immediate input reports failure', () => { // So a reconnect / retry will re-emit on the next appearance pass. const transport = fakeTransport({ sendOk: false }) const subs = new Map([[1, true]]) @@ -112,7 +115,7 @@ describe('maybePushMode2031Flip', () => { const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last) expect(pushed).toBe(false) - expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1) expect(last.has(1)).toBe(false) }) @@ -130,8 +133,8 @@ describe('maybePushMode2031Flip', () => { maybePushMode2031Flip(1, 'dark', transportA, subs, last) // suppressed maybePushMode2031Flip(2, 'dark', transportB, subs, last) // flip - expect(transportA.sendInput).toHaveBeenCalledTimes(1) - expect(transportB.sendInput).toHaveBeenCalledTimes(2) + expect(transportA.sendInputImmediate).toHaveBeenCalledTimes(1) + expect(transportB.sendInputImmediate).toHaveBeenCalledTimes(2) expect(last.get(1)).toBe('dark') expect(last.get(2)).toBe('dark') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index a34ac215e66..d1af2c9f1af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -1,7 +1,6 @@ import type { IDisposable, IParser, ITheme } from '@xterm/xterm' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { GlobalSettings } from '../../../../shared/types' -import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures' import { @@ -24,8 +23,7 @@ import { HEX_COLOR_RE } from '../../../../shared/color-validation' import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' - -export { mode2031SequenceFor } +import { maybePushMode2031Flip } from './terminal-mode-2031-replies' // Why Pick over a hand-rolled structural type: keeps the helper // tied to xterm's canonical signature so any upstream tightening (added @@ -112,33 +110,6 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ ] } -// Gate on actual mode flip so font/size/opacity tweaks — which also re-run -// applyTerminalAppearance — don't spam subscribed TUIs with CSI 997. The -// subscribe/last-mode maps are mutated in place so callers share state with -// the lifecycle hook's seed path. -export function maybePushMode2031Flip( - paneId: number, - mode: 'dark' | 'light', - transport: Pick, - paneMode2031: Map, - paneLastThemeMode: Map -): boolean { - if (!transport.isConnected()) { - return false - } - if (!paneMode2031.get(paneId)) { - return false - } - if (paneLastThemeMode.get(paneId) === mode) { - return false - } - if (!transport.sendInput(mode2031SequenceFor(mode))) { - return false - } - paneLastThemeMode.set(paneId, mode) - return true -} - export function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') if (clean.length === 3) { diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts new file mode 100644 index 00000000000..ca88cb7fe8d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { pushMode2031SeedReply } from './terminal-mode-2031-replies' + +describe('pushMode2031SeedReply', () => { + function createHarness(connected: boolean): { + connected: { current: boolean } + subscribed: { current: boolean } + sendInput: ReturnType boolean>> + sendInputImmediate: ReturnType boolean>> + scheduled: (() => void)[] + recordMode: ReturnType + push: () => void + } { + const connectedState = { current: connected } + const subscribed = { current: true } + const sendInput = vi.fn<(data: string) => boolean>(() => true) + const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true) + const scheduled: (() => void)[] = [] + const recordMode = vi.fn() + const transport = { + isConnected: () => connectedState.current, + sendInput, + sendInputImmediate + } + return { + connected: connectedState, + subscribed, + sendInput, + sendInputImmediate, + scheduled, + recordMode, + push: () => + pushMode2031SeedReply(1, { + hasPane: () => true, + isSubscribed: () => subscribed.current, + getTransport: () => transport, + getMode: () => 'dark', + recordMode, + schedule: (callback) => scheduled.push(callback) + }) + } + } + + it('routes the color-scheme response through latency-critical input', () => { + const harness = createHarness(true) + + harness.push() + + expect(harness.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n') + expect(harness.sendInput).not.toHaveBeenCalled() + expect(harness.recordMode).toHaveBeenCalledWith(1, 'dark') + }) + + it('cancels a pre-connect retry after the program unsubscribes', () => { + const harness = createHarness(false) + + harness.push() + expect(harness.scheduled).toHaveLength(1) + + harness.subscribed.current = false + harness.connected.current = true + harness.scheduled.shift()?.() + + expect(harness.sendInputImmediate).not.toHaveBeenCalled() + expect(harness.sendInput).not.toHaveBeenCalled() + expect(harness.scheduled).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts new file mode 100644 index 00000000000..e30961692f8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts @@ -0,0 +1,75 @@ +import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { PtyTransport } from './pty-transport' + +const MODE_2031_CONNECT_RETRY_MS = 25 +const MODE_2031_CONNECT_ATTEMPTS = 8 + +type Mode2031ReplyTransport = Pick + +type Mode2031SeedReplyDeps = { + hasPane: (paneId: number) => boolean + isSubscribed: (paneId: number) => boolean + getTransport: (paneId: number) => Mode2031ReplyTransport | undefined + getMode: () => TerminalColorSchemeMode | null + recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void + schedule: (callback: () => void, delayMs: number) => void +} + +function sendMode2031Reply( + transport: Mode2031ReplyTransport, + mode: TerminalColorSchemeMode +): boolean { + // Why: fish stops reading mode-2031 replies quickly; remote input batching + // can otherwise deliver this terminal response after the shell regains input. + return transport.sendInputImmediate(mode2031SequenceFor(mode)) +} + +export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDeps): void { + let attempts = 0 + const send = (): void => { + // Why: a TUI can unsubscribe while the PTY is connecting; every delayed + // attempt must revalidate intent or its color reply can land at a shell prompt. + if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId)) { + return + } + const transport = deps.getTransport(paneId) + if (!transport?.isConnected()) { + attempts += 1 + if (attempts < MODE_2031_CONNECT_ATTEMPTS) { + deps.schedule(send, MODE_2031_CONNECT_RETRY_MS) + } + return + } + const mode = deps.getMode() + if (mode && sendMode2031Reply(transport, mode)) { + deps.recordMode(paneId, mode) + } + } + send() +} + +// Appearance updates include font and opacity changes, so only report actual +// color-mode flips to programs that still have mode 2031 enabled. +export function maybePushMode2031Flip( + paneId: number, + mode: TerminalColorSchemeMode, + transport: Mode2031ReplyTransport, + paneMode2031: Map, + paneLastThemeMode: Map +): boolean { + if (!transport.isConnected()) { + return false + } + if (!paneMode2031.get(paneId)) { + return false + } + if (paneLastThemeMode.get(paneId) === mode) { + return false + } + if (!sendMode2031Reply(transport, mode)) { + return false + } + paneLastThemeMode.set(paneId, mode) + return true +} 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 372a222e9ac..c39d97453e3 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 @@ -58,11 +58,8 @@ import { import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' -import { - applyTerminalAppearance, - installMode2031Handlers, - mode2031SequenceFor -} from './terminal-appearance' +import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance' +import { pushMode2031SeedReply } from './terminal-mode-2031-replies' import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' @@ -604,34 +601,22 @@ export function useTerminalPaneLifecycle({ } const pushMode2031ForPane = (paneId: number): void => { - let attempts = 0 - const send = (): void => { - if (!managerRef.current?.getPanes().some((pane) => pane.id === paneId)) { - return + pushMode2031SeedReply(paneId, { + hasPane: (candidateId) => + managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true, + isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true, + getTransport: (candidateId) => paneTransportsRef.current.get(candidateId), + getMode: () => { + const currentSettings = settingsRef.current + return currentSettings + ? resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDarkRef.current).mode + : null + }, + recordMode: (candidateId, mode) => paneLastThemeModeRef.current.set(candidateId, mode), + schedule: (callback, delayMs) => { + window.setTimeout(callback, delayMs) } - const transport = paneTransportsRef.current.get(paneId) - if (!transport?.isConnected()) { - // Why: TUIs can subscribe before pty:spawn resolves. Retry briefly so - // the recorded subscription still receives the initial dark/light seed. - attempts += 1 - if (attempts < 8) { - window.setTimeout(send, 25) - } - return - } - const currentSettings = settingsRef.current - if (!currentSettings) { - return - } - const { mode } = resolveEffectiveTerminalAppearance( - currentSettings, - systemPrefersDarkRef.current - ) - if (transport.sendInput(mode2031SequenceFor(mode))) { - paneLastThemeModeRef.current.set(paneId, mode) - } - } - send() + }) } // Initialize PaneManager instance once From e3f0b99f4abb80a4ae9e930786713d49d2be0baf Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:31:53 -0300 Subject: [PATCH 84/95] fix: detect noisy Windows ARM64 SSH platforms (#7965) * fix: detect noisy Windows ARM64 SSH platforms - Scan remote platform probe output line-by-line for supported OS/arch markers. - Handle PowerShell/OpenSSH first-use CLIXML or banner noise before Windows ARM64. - Add regression coverage for noisy Windows ARM64 detection. * fix: harden SSH platform probe markers --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- .../ssh-relay-cross-version-isolation.test.ts | 2 +- src/main/ssh/ssh-relay-deploy.test.ts | 61 ++++++++++--------- .../ssh/ssh-relay-native-deps-install.test.ts | 16 +++-- .../ssh/ssh-remote-platform-detection.test.ts | 51 ++++++++++++++-- src/main/ssh/ssh-remote-platform-detection.ts | 43 +++++++++---- src/main/ssh/ssh-remote-platform.test.ts | 5 +- 6 files changed, 123 insertions(+), 55 deletions(-) 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 c426e616fed..a29801c3632 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -98,7 +98,7 @@ describe('cross-version isolation', () => { // $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec), // npm install, finalize, socket probe, socket poll, then GC scan. const responses: string[] = [ - 'Linux x86_64', // uname -sm + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) '', // mkdir -p remoteRelayDir (v2) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index ec825cdc4b6..33467819035 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,7 +42,7 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), - execCommand: vi.fn().mockResolvedValue('Linux x86_64') + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) vi.mock('./ssh-remote-node-resolution', () => ({ @@ -123,7 +123,7 @@ describe('deployAndLaunchRelay', () => { it('calls exec to detect remote platform', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -131,13 +131,16 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(mockExecCommand).toHaveBeenCalledWith(conn, 'uname -sm') + expect(mockExecCommand).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('reports progress via callback', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe @@ -153,7 +156,7 @@ describe('deployAndLaunchRelay', () => { it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -167,7 +170,7 @@ describe('deployAndLaunchRelay', () => { 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 + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let markNodeResolutionStarted: () => void = () => {} const nodeResolutionStarted = new Promise((resolve) => { @@ -221,7 +224,7 @@ describe('deployAndLaunchRelay', () => { const conn = makeMockConnection() vi.mocked(conn.canRunConcurrentExecCommands).mockReturnValue(false) const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeProbeStarted: () => void = () => {} const remoteHomeProbeStartedPromise = new Promise((resolve) => { @@ -274,7 +277,7 @@ describe('deployAndLaunchRelay', () => { fallbackInstallStateCompleted = true return true }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockRejectedValueOnce(sessionLimitError) // concurrent node path probe mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME @@ -302,7 +305,7 @@ describe('deployAndLaunchRelay', () => { throw sessionLimitError }) .mockResolvedValueOnce(true) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME mockExecCommand.mockResolvedValueOnce('/home/user') // sequential fallback $HOME mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe @@ -324,7 +327,7 @@ describe('deployAndLaunchRelay', () => { 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('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(nodeError) @@ -346,7 +349,7 @@ describe('deployAndLaunchRelay', () => { }) }) }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('relative-home') // invalid install-state $HOME const timedDeploy = Promise.race([ @@ -370,7 +373,7 @@ describe('deployAndLaunchRelay', () => { 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('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe mockExecCommand.mockResolvedValueOnce('/home/user') // concurrent install-state $HOME await expect(deployAndLaunchRelay(conn)).rejects.toBe(installError) @@ -384,7 +387,7 @@ describe('deployAndLaunchRelay', () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 }) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe let releaseRemoteHome: (home: string) => void = () => {} let remoteHomeSettled = false mockExecCommand.mockReturnValueOnce( @@ -420,7 +423,7 @@ describe('deployAndLaunchRelay', () => { it('defaults fresh relays to keep-alive-until-reset', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -439,7 +442,7 @@ describe('deployAndLaunchRelay', () => { it('allows an unlimited SSH disconnect grace window', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -458,7 +461,7 @@ describe('deployAndLaunchRelay', () => { it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -477,7 +480,7 @@ describe('deployAndLaunchRelay', () => { it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') mockExecCommand.mockResolvedValueOnce('/home/user') mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') mockExecCommand.mockResolvedValueOnce('DEAD') @@ -522,12 +525,12 @@ describe('deployAndLaunchRelay', () => { const connB = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) mockExecCommand - .mockResolvedValueOnce('Linux x86_64') // uname A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe A .mockResolvedValueOnce('/home/user') // $HOME A .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe A .mockResolvedValueOnce('DEAD') // probe A .mockResolvedValueOnce('READY') // poll A - .mockResolvedValueOnce('Linux x86_64') // uname B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe B .mockResolvedValueOnce('/home/user') // $HOME B .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe B .mockResolvedValueOnce('DEAD') // probe B @@ -561,8 +564,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe @@ -607,8 +610,8 @@ describe('deployAndLaunchRelay', () => { onClose: vi.fn() }) mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe yet @@ -654,8 +657,8 @@ describe('deployAndLaunchRelay', () => { const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname -sm - .mockResolvedValueOnce('Windows X64') // PowerShell platform probe + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe .mockResolvedValueOnce('C:\\Users\\me user') // remote home .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker @@ -682,8 +685,8 @@ describe('deployAndLaunchRelay', () => { const mockExecCommand = vi.mocked(execCommand) vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // uname A - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe A @@ -691,8 +694,8 @@ describe('deployAndLaunchRelay', () => { .mockResolvedValueOnce('') .mockResolvedValueOnce('READY') .mockResolvedValueOnce('') // persist active pipe A - .mockRejectedValueOnce(new Error('uname not found')) // uname B - .mockResolvedValueOnce('Windows X64') + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') .mockResolvedValueOnce('C:\\Users\\me user') .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') .mockResolvedValueOnce('') // no persisted active pipe B 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 2b63b3a2d50..4d2f92eb947 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -152,7 +152,7 @@ function makeExecResponses(opts: { // build toolchain — no chmod/probe/launch slots are reached. if (opts.npmInstall !== 'ok') { return [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -171,7 +171,7 @@ function makeExecResponses(opts: { ? { reject: 'cd: no such file or directory' } : opts.probe const slots: ExecResponse[] = [ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -530,7 +530,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe') const conn = makeMockConnection(sftpCapture) feed([ - 'Windows AMD64', + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', 'C:\\Users\\u', '', // mkdir remoteDir '', // npm install native deps @@ -605,7 +605,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) feed([ - 'Linux x86_64', + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', // first native-deps probe before lock 'MISSING', // re-probe after lock @@ -632,7 +632,13 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { it('does not mutate an existing relay dir when required native deps are present', async () => { vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) const conn = makeMockConnection(sftpCapture) - feed(['Linux x86_64', '/home/u', 'ORCA-NATIVE-DEPS-OK', 'DEAD', 'READY']) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) await deployAndLaunchRelay(conn) diff --git a/src/main/ssh/ssh-remote-platform-detection.test.ts b/src/main/ssh/ssh-remote-platform-detection.test.ts index a8ffd416830..71fc4e7c0cd 100644 --- a/src/main/ssh/ssh-remote-platform-detection.test.ts +++ b/src/main/ssh/ssh-remote-platform-detection.test.ts @@ -11,13 +11,18 @@ const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detecti const conn = {} as SshConnection +function decodePowerShellCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' +} + describe('detectRemoteHostPlatform', () => { beforeEach(() => { execCommandMock.mockReset() }) it('detects POSIX hosts from uname output', async () => { - execCommandMock.mockResolvedValueOnce('Linux x86_64\n') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'linux-x64', @@ -25,13 +30,16 @@ describe('detectRemoteHostPlatform', () => { arch: 'x64', pathFlavor: 'posix' }) - expect(execCommandMock).toHaveBeenCalledWith(conn, 'uname -sm') + expect(execCommandMock).toHaveBeenCalledWith( + conn, + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + ) }) it('falls back to PowerShell detection for Windows remotes', async () => { execCommandMock .mockRejectedValueOnce(new Error('uname unavailable')) - .mockResolvedValueOnce('Windows AMD64\r\n') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64\r\n') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -45,17 +53,50 @@ describe('detectRemoteHostPlatform', () => { expect.stringContaining('powershell.exe'), { wrapCommand: false } ) + const command = execCommandMock.mock.calls[1]?.[1] ?? '' + expect(decodePowerShellCommand(command)).toContain( + 'Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)' + ) + }) + + it('ignores untagged platforms before the tagged Windows ARM64 result', async () => { + execCommandMock + .mockRejectedValueOnce(new Error('uname unavailable')) + .mockResolvedValueOnce( + 'Linux x86_64\r\nWindows AMD64\r\n#< CLIXML\r\n' + + '__ORCA_REMOTE_PLATFORM__ Windows ARM64\r\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'win32-arm64', + os: 'win32', + arch: 'arm64', + pathFlavor: 'windows' + }) + }) + + it('ignores a marker concatenated to unterminated startup noise', async () => { + execCommandMock.mockResolvedValueOnce( + 'startup noise__ORCA_REMOTE_PLATFORM__ Linux x86_64\n' + + '__ORCA_REMOTE_PLATFORM__ Linux arm64\n' + ) + + await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ + relayPlatform: 'linux-arm64' + }) }) it('returns null when neither probe yields a supported platform', async () => { - execCommandMock.mockResolvedValueOnce('Linux').mockResolvedValueOnce('FreeBSD x86_64') + execCommandMock + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ FreeBSD x86_64') await expect(detectRemoteHostPlatform(conn)).resolves.toBeNull() }) it('does not use whitespace regex splitting for remote platform output', async () => { const splitSpy = vi.spyOn(String.prototype, 'split') - execCommandMock.mockResolvedValueOnce('Darwin arm64 extra') + execCommandMock.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64 extra') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64' diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f31f53f4ec4..f72b5384335 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -1,10 +1,15 @@ import type { SshConnection } from './ssh-connection' -import { getProcessOutputFields } from '../../shared/process-output-field-scanner' +import { + getProcessOutputFields, + iterateProcessOutputLines +} from '../../shared/process-output-field-scanner' import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' import { powerShellCommand } from './ssh-remote-powershell' +const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' + export async function detectRemoteHostPlatform( conn: SshConnection ): Promise { @@ -18,12 +23,12 @@ export async function detectRemoteHostPlatform( async function detectUnamePlatform(conn: SshConnection): Promise { try { - const output = await execCommand(conn, 'uname -sm') - const parts = getProcessOutputFields(output, 2) - if (parts.length < 2) { - return null - } - return parseUnameToRelayPlatform(parts[0], parts[1]) + const output = await execCommand( + conn, + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + ) + return parseRemotePlatformOutput(output) } catch { return null } @@ -35,15 +40,27 @@ async function detectWindowsPlatform(conn: SshConnection): Promise { describe('detectRemoteHostPlatform', () => { it('uses uname when the remote is POSIX', async () => { - vi.mocked(execCommand).mockResolvedValueOnce('Darwin arm64') + vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'darwin-arm64', @@ -46,7 +46,7 @@ describe('detectRemoteHostPlatform', () => { it('falls back to PowerShell when uname is unavailable on Windows', async () => { vi.mocked(execCommand) .mockRejectedValueOnce(new Error('uname not recognized')) - .mockResolvedValueOnce('Windows AMD64') + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows AMD64') await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({ relayPlatform: 'win32-x64', @@ -59,5 +59,6 @@ describe('detectRemoteHostPlatform', () => { expect(script).toContain('$arch = $env:PROCESSOR_ARCHITECTURE') expect(script).toContain('try { $runtimeArch =') expect(script).toContain('catch {}') + expect(script).toContain('Write-Output ("`n__ORCA_REMOTE_PLATFORM__ Windows " + $arch)') }) }) From c869891b3749b1808dc99b083a118dce4aff3e56 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 10 Jul 2026 22:43:12 -0700 Subject: [PATCH 85/95] test(e2e): defer large repo cleanup until shutdown (#8228) --- tests/e2e/helpers/orca-app.ts | 27 +++++++- .../source-control-large-file-count.spec.ts | 63 ++++++++++++++----- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index eb49dfeba5e..4d7f1b9ef37 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -32,6 +32,7 @@ import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication + registerPostElectronShutdownCleanup: (cleanup: () => Promise) => void sharedPage: Page orcaPage: Page // Why: every fresh userData dir paints the first-launch onboarding overlay @@ -145,12 +146,36 @@ export const test = base.extend({ { scope: 'worker' } ], + // Why: Windows keeps watched worktrees locked until Electron and its + // detached test daemons exit. Tests register fixture cleanup here so it runs + // after electronApp teardown instead of masking the real assertion failure. + registerPostElectronShutdownCleanup: [ + // oxlint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here. + async ({}, provideFixture) => { + const cleanups: (() => Promise)[] = [] + await provideFixture((cleanup) => cleanups.push(cleanup)) + for (const cleanup of cleanups.toReversed()) { + await cleanup() + } + }, + { scope: 'test' } + ], + // Test-scoped: one Electron app per test electronApp: async ( - { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + { + dismissOnboarding, + launchEnv, + orcaAppExtraEnv, + orcaAppExtraArgs, + registerPostElectronShutdownCleanup + }, provideFixture, testInfo ) => { + // Establish fixture ordering: registered path cleanup must run only after + // this Electron fixture has released watchers, terminals, and daemons. + void registerPostElectronShutdownCleanup const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) diff --git a/tests/e2e/source-control-large-file-count.spec.ts b/tests/e2e/source-control-large-file-count.spec.ts index 2eff3d2845f..c54a9651c43 100644 --- a/tests/e2e/source-control-large-file-count.spec.ts +++ b/tests/e2e/source-control-large-file-count.spec.ts @@ -102,17 +102,36 @@ async function addAndActivateRepo(orcaPage: Page, repoPath: string): Promise orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab)) .toBe('source-control') + await expect(orcaPage.getByRole('button', { name: 'Filter files by name' })).toBeVisible() return worktreeId } +async function unregisterLargeFileCountRepos( + orcaPage: Page, + repoPaths: readonly string[] +): Promise { + // Why: remove disposable projects through the product so their terminals + // and watcher subscriptions begin shutting down before Electron teardown. + for (const repoPath of repoPaths) { + await orcaPage.evaluate(async (pathToRepo) => { + const store = window.__store + const repo = store?.getState().repos.find((entry) => entry.path === pathToRepo) + if (repo) { + await store?.getState().removeProject(repo.id) + } + }, repoPath) + } +} + /** * Drives the exact pipeline the panel's poll uses (api.git.status → * setGitStatus) so results are deterministic even while the built-in 3s poll @@ -242,7 +261,8 @@ test.describe('Source Control large file count (#8013)', () => { test('thousands of untracked files under the status cap stay responsive', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '9500') @@ -255,6 +275,7 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles, untrackedFileBytes }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -286,17 +307,19 @@ test.describe('Source Control large file count (#8013)', () => { ) } } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('thousands of modified tracked files under the status cap stay responsive', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const modifiedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '5000') const fixture = createLargeFileCountRepo({ trackedFiles: modifiedFiles, modifiedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -319,17 +342,19 @@ test.describe('Source Control large file count (#8013)', () => { expect(measurement.renderedRows).toBeLessThan(MAX_MOUNTED_ROWS) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) test('a change set over the status cap degrades to the too-many-changes state', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const untrackedFiles = DEFAULT_GIT_STATUS_LIMIT + 1_000 const fixture = createLargeFileCountRepo({ trackedFiles: 100, untrackedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -361,11 +386,14 @@ test.describe('Source Control large file count (#8013)', () => { ) expect(hugeState).not.toBeNull() } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) - test('untracked line-stat cache stays effective above 2,048 files', async ({ orcaPage }) => { + test('untracked line-stat cache stays effective above 2,048 files', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }) => { test.setTimeout(600_000) // Why: the untracked line-stat cache historically capped at 2,048 entries // with FIFO eviction, so a sequential scan over more files evicted every @@ -380,6 +408,7 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles: 2_000, untrackedFileBytes: fileBytes }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(smallRepo.repoPath)) let largeRepo: ReturnType | null = null try { largeRepo = createLargeFileCountRepo({ @@ -387,6 +416,8 @@ test.describe('Source Control large file count (#8013)', () => { untrackedFiles: 4_000, untrackedFileBytes: fileBytes }) + const largeRepoPath = largeRepo.repoPath + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(largeRepoPath)) await waitForSessionReady(orcaPage) const warmRescanPerFileMs = async (repoPath: string, files: number): Promise => { @@ -407,20 +438,22 @@ test.describe('Source Control large file count (#8013)', () => { ) expect(largePerFileMs).toBeLessThan(smallPerFileMs * 2) } finally { - await removeLargeFileCountRepo(smallRepo.repoPath) - if (largeRepo) { - await removeLargeFileCountRepo(largeRepo.repoPath) - } + await unregisterLargeFileCountRepos(orcaPage, [ + smallRepo.repoPath, + ...(largeRepo ? [largeRepo.repoPath] : []) + ]) } }) test('a large clean repo (tracked files only) loads instantly', async ({ orcaPage, - electronApp + electronApp, + registerPostElectronShutdownCleanup }) => { test.setTimeout(600_000) const trackedFiles = Number(process.env.ORCA_LARGE_FILE_COUNT ?? '15000') const fixture = createLargeFileCountRepo({ trackedFiles }) + registerPostElectronShutdownCleanup(() => removeLargeFileCountRepo(fixture.repoPath)) try { await waitForSessionReady(orcaPage) const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) @@ -440,7 +473,7 @@ test.describe('Source Control large file count (#8013)', () => { expect(measurement.entryCount).toBe(0) expect(measurement.maxLagMs).toBeLessThan(MAX_EVENT_LOOP_LAG_MS) } finally { - await removeLargeFileCountRepo(fixture.repoPath) + await unregisterLargeFileCountRepos(orcaPage, [fixture.repoPath]) } }) }) From 4f7fe78921a7c016f78c8dd266dffe17682f25cb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:44:59 -0700 Subject: [PATCH 86/95] perf(mobile): stop polling live worktree names (#8051) * perf(mobile): replace worktree name polling with events * fix(worktrees): push rename invalidation to remote clients worktrees:updateMeta deliberately skips the renderer notifier (PR #209), but paired mobile clients no longer poll for titles, so a manual rename would never reach them. Emit the remote-only worktreesChanged client event (with resolved-cache invalidation), gated on displayName so per-click isUnread writes stay event-free. Co-authored-by: Orca * test(worktrees): add missing runtimeStub type member for typecheck Co-authored-by: Orca * fix(worktrees): derive rename event repoId with the shared non-throwing parser getRepoIdFromWorktreeId matches the mobile client's event filter exactly and cannot throw after the meta write already persisted. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../session/use-live-worktree-name.test.ts | 169 ++++++++++++++++++ mobile/src/session/use-live-worktree-name.ts | 85 ++++++++- .../rpc-client-runtime-events.test.ts | 135 ++++++++++++++ mobile/src/transport/rpc-client.ts | 43 ++++- src/main/ipc/worktrees.test.ts | 22 ++- src/main/ipc/worktrees.ts | 13 +- src/main/runtime/orca-runtime.ts | 8 + 7 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 mobile/src/session/use-live-worktree-name.test.ts create mode 100644 mobile/src/transport/rpc-client-runtime-events.test.ts diff --git a/mobile/src/session/use-live-worktree-name.test.ts b/mobile/src/session/use-live-worktree-name.test.ts new file mode 100644 index 00000000000..97927c6b7d9 --- /dev/null +++ b/mobile/src/session/use-live-worktree-name.test.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useLiveWorktreeName } from './use-live-worktree-name' + +vi.mock('expo-router', async () => { + const React = await import('react') + return { + useFocusEffect(effect: () => void | (() => void)): void { + React.useEffect(effect, [effect]) + } + } +}) + +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() +} + +describe('useLiveWorktreeName request volume', () => { + let renderer: ReactTestRenderer | null = null + let eventListener: ((payload: unknown) => void) | null = null + const unsubscribeStream = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + const subscribe = vi.fn( + (_method: string, _params: unknown, listener: (payload: unknown) => void) => { + eventListener = listener + return unsubscribeStream + } + ) + const client = { sendRequest, subscribe } as unknown as RpcClient + + async function mountHarness(): Promise { + function Harness(): null { + useLiveWorktreeName({ + client, + connState: 'connected', + routeName: 'Route name', + worktreeId: 'repo-1::/worktree' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + restoreConsoleError() + } + } + + async function emitEvent(payload: unknown): Promise { + await act(async () => { + eventListener?.(payload) + await Promise.resolve() + await Promise.resolve() + }) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + eventListener = null + sendRequest.mockClear().mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + subscribe.mockClear() + unsubscribeStream.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('cuts thirty-second idle request volume from eleven calls to one', async () => { + await mountHarness() + expect(subscribe).toHaveBeenCalledWith( + 'runtime.clientEvents.subscribe', + null, + expect.any(Function) + ) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + // The retired 3s interval made one initial request plus ten idle ticks here. + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-2' }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await emitEvent({ type: 'worktreesChanged', repoId: 'repo-1' }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps polling when an older runtime rejects the event stream', async () => { + await mountHarness() + await emitEvent({ type: 'error', message: 'Unknown method' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(9_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(4) + }) + + it('retries a failed initial read even after the event stream is ready', async () => { + sendRequest.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({ + id: 'worktree-show', + ok: true, + result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } }, + _meta: { runtimeId: 'runtime-1' } + }) + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + await vi.advanceTimersByTimeAsync(30_000) + }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('refreshes once when a reconnected stream becomes ready again', async () => { + await mountHarness() + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' }) + await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-2' }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('unsubscribes when the screen loses focus', async () => { + await mountHarness() + act(() => renderer?.unmount()) + renderer = null + + expect(unsubscribeStream).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-live-worktree-name.ts b/mobile/src/session/use-live-worktree-name.ts index 665fcea3a1e..bd21ec8f156 100644 --- a/mobile/src/session/use-live-worktree-name.ts +++ b/mobile/src/session/use-live-worktree-name.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useState } from 'react' import { useFocusEffect } from 'expo-router' +import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events' +import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcSuccess } from '../transport/types' import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name' +const WORKTREE_NAME_FALLBACK_POLL_MS = 3000 + type Params = { client: RpcClient | null connState: ConnectionState @@ -24,12 +28,27 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } return } let stale = false - const refreshWorktreeName = async () => { + let eventStreamReady = false + let hasSuccessfulRefresh = false + let fallbackInterval: ReturnType | null = null + let refreshGeneration = 0 + const repoId = getRepoIdFromWorktreeId(worktreeId) + + const stopFallbackPoll = (): void => { + if (fallbackInterval !== null) { + clearInterval(fallbackInterval) + fallbackInterval = null + } + } + const refreshWorktreeName = async (): Promise => { + // Why: an event-driven refresh can overtake a slow fallback request; + // only the newest read may publish or stop the retry poll. + const generation = ++refreshGeneration try { const response = await client.sendRequest('worktree.show', { worktree: `id:${worktreeId}` }) - if (stale || !response.ok) { + if (stale || generation !== refreshGeneration || !response.ok) { return } const result = (response as RpcSuccess).result as { @@ -41,17 +60,73 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId } if (liveName) { setWorktreeName((current) => (current === liveName ? current : liveName)) } + hasSuccessfulRefresh = true + if (eventStreamReady) { + stopFallbackPoll() + } } catch { // Non-fatal: the route param remains a usable label until the next refresh. } } + + const startFallbackPoll = (): void => { + if (stale || fallbackInterval !== null) { + return + } + fallbackInterval = setInterval( + () => void refreshWorktreeName(), + WORKTREE_NAME_FALLBACK_POLL_MS + ) + } + const invalidateAndRefresh = (): void => { + hasSuccessfulRefresh = false + startFallbackPoll() + void refreshWorktreeName() + } + + startFallbackPoll() + const unsubscribe = client.subscribe( + 'runtime.clientEvents.subscribe', + null, + (payload: unknown) => { + if (stale || !payload || typeof payload !== 'object') { + return + } + const event = payload as RuntimeClientEventStreamMessage | { type: 'error' } + if (event.type === 'ready') { + const replayedAfterReconnect = eventStreamReady + eventStreamReady = true + if (hasSuccessfulRefresh) { + stopFallbackPoll() + } + if (replayedAfterReconnect) { + // Why: client events are not queued while disconnected, so replay + // readiness must re-read the title once to close that event gap. + invalidateAndRefresh() + } + return + } + if (event.type === 'end' || event.type === 'error') { + eventStreamReady = false + startFallbackPoll() + return + } + if ( + event.type === 'reposChanged' || + (event.type === 'worktreesChanged' && event.repoId === repoId) + ) { + invalidateAndRefresh() + } + } + ) // Why: route params are only an entry hint. The desktop/runtime owns - // displayName, including task-generated names that may settle after open. + // displayName. Modern runtimes push invalidations; the poll remains only + // until that stream proves available, preserving older-runtime behavior. void refreshWorktreeName() - const interval = setInterval(() => void refreshWorktreeName(), 3000) return () => { stale = true - clearInterval(interval) + stopFallbackPoll() + unsubscribe() } }, [client, connState, worktreeId]) ) diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts new file mode 100644 index 00000000000..d18739423f7 --- /dev/null +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' + +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.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class RuntimeEventTestSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = RuntimeEventTestSocket.CONNECTING + readonly OPEN = RuntimeEventTestSocket.OPEN + readonly CLOSING = RuntimeEventTestSocket.CLOSING + readonly CLOSED = RuntimeEventTestSocket.CLOSED + + readyState = RuntimeEventTestSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + sent: string[] = [] + + constructor(readonly endpoint: string) { + sockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + close(): void { + this.readyState = RuntimeEventTestSocket.CLOSED + this.onclose?.() + } + + open(): void { + this.readyState = RuntimeEventTestSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +type SentRequest = { id: string; method: string; params?: unknown } + +const sockets: RuntimeEventTestSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] { + return socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest) + .filter((request) => request.method === method) +} + +function connectReadyClient(): { client: RpcClient; socket: RuntimeEventTestSocket } { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = sockets[0]! + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + return { client, socket } +} + +function emitReady( + socket: RuntimeEventTestSocket, + requestId: string, + subscriptionId: string +): void { + socket.receive( + `encrypted:${JSON.stringify({ + id: requestId, + ok: true, + streaming: true, + result: { type: 'ready', subscriptionId }, + _meta: { runtimeId: 'r1' } + })}` + ) +} + +describe('runtime client-event stream disposal', () => { + beforeEach(() => { + vi.useFakeTimers() + sockets.length = 0 + globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + globalThis.WebSocket = originalWebSocket + vi.useRealTimers() + }) + + it('unsubscribes a stream disposed after ready', () => { + const { client, socket } = connectReadyClient() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, () => {}) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + emitReady(socket, request.id, 'runtime-events:test') + + unsubscribe() + + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:test' } }) + ]) + client.close() + }) + + it('keeps a tombstone and unsubscribes a stream disposed before ready', () => { + const { client, socket } = connectReadyClient() + const listener = vi.fn() + const unsubscribe = client.subscribe('runtime.clientEvents.subscribe', null, listener) + const request = sentRequests(socket, 'runtime.clientEvents.subscribe')[0]! + + unsubscribe() + emitReady(socket, request.id, 'runtime-events:late') + + expect(listener).not.toHaveBeenCalled() + expect(sentRequests(socket, 'runtime.clientEvents.unsubscribe')).toEqual([ + expect.objectContaining({ params: { subscriptionId: 'runtime-events:late' } }) + ]) + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index b9d0c990261..014bea955cd 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -567,10 +567,10 @@ export function connect( const stream = streamListeners.get(response.id) if (stream && response.ok) { const result = (response as RpcSuccess).result - if (isBrowserScreencastReadyResult(result)) { + if (isStreamingSubscriptionReadyResult(result)) { stream.subscriptionId = result.subscriptionId if (stream.cancelled) { - sendBrowserScreencastUnsubscribe(result.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(response.id) return } @@ -947,8 +947,21 @@ export function connect( if (pendingBrowserScreencastRequestId === id) { pendingBrowserScreencastRequestId = null } + disposeServerSubscriptionStream(id, stream) + } + + function disposeRuntimeClientEventsStream(id: string): void { + const stream = streamListeners.get(id) + if (!stream || stream.method !== 'runtime.clientEvents.subscribe') { + return + } + disposeServerSubscriptionStream(id, stream) + } + + function disposeServerSubscriptionStream(id: string, stream: StreamRequest): void { + stream.cancelled = true if (stream.subscriptionId) { - sendBrowserScreencastUnsubscribe(stream.subscriptionId) + sendServerSubscriptionUnsubscribe(stream) removeStreamListener(id) return } @@ -1022,6 +1035,24 @@ export function connect( }) } + function sendServerSubscriptionUnsubscribe(stream: StreamRequest): void { + if (!stream.subscriptionId) { + return + } + if (stream.method === 'browser.screencast') { + sendBrowserScreencastUnsubscribe(stream.subscriptionId) + return + } + if (stream.method === 'runtime.clientEvents.subscribe') { + sendEncrypted({ + id: nextId(), + deviceToken, + method: 'runtime.clientEvents.unsubscribe', + params: { subscriptionId: stream.subscriptionId } + }) + } + } + openConnection() return { @@ -1120,6 +1151,10 @@ export function connect( disposeBrowserScreencastStream(id) return } + if (stream?.method === 'runtime.clientEvents.subscribe') { + disposeRuntimeClientEventsStream(id) + return + } if (stream?.method === 'terminal.subscribe') { // Why: the runtime registers cleanup under the composite key // `${terminal}:${clientId}` so two phones subscribing to the same @@ -1243,7 +1278,7 @@ function isTerminalSubscribedResult( ) } -function isBrowserScreencastReadyResult( +function isStreamingSubscriptionReadyResult( value: unknown ): value is { type: 'ready'; subscriptionId: string } { return ( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 8e128912517..a278656ee09 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -282,6 +282,7 @@ describe('registerWorktreeHandlers', () => { resolveManagedMrBase: ReturnType createTerminal: ReturnType splitTerminal: ReturnType + notifyWorktreesChangedForRemoteClients: ReturnType } beforeEach(() => { @@ -490,7 +491,8 @@ describe('registerWorktreeHandlers', () => { handle: 'term-setup', tabId: 'tab-startup', paneRuntimeId: -1 - }) + }), + notifyWorktreesChangedForRemoteClients: vi.fn() } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) @@ -742,6 +744,24 @@ describe('registerWorktreeHandlers', () => { expect(result).toMatchObject({ comment: 'keep me', isPinned: true }) }) + it('pushes a remote-client invalidation for renames but not read-state updates', () => { + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { isUnread: false } + }) + // Why: per-click isUnread writes must stay event-free (PR #209), while a + // rename must reach paired remote clients that no longer poll for titles. + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled() + + handlers['worktrees:updateMeta'](null, { + worktreeId: 'repo-1::/workspace/feature-wt', + updates: { displayName: 'Renamed workspace' } + }) + expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1') + }) + it('does not trust renderer-authored automation provenance during local create', async () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) listWorktreesMock.mockResolvedValue([ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 92e626b2e3a..4128cbe69de 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -150,7 +150,10 @@ import { } from '../worktree-removal-safety' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId +} from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { getLocalProjectGitExecOptions, @@ -2017,6 +2020,14 @@ export function registerWorktreeHandlers( // sortEpoch and reorders the sidebar — the exact bug PR #209 tried // to fix (clicking a card would clear isUnread → updateMeta → // worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort). + if (args.updates.displayName !== undefined) { + // Why: paired remote clients have no optimistic copy of the rename and + // no longer poll for titles, so push the remote-only invalidation. + // Gated on displayName to keep isUnread-per-click updates event-free. + // getRepoIdFromWorktreeId matches the mobile client's event filter and + // never throws after the meta write already succeeded. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } return meta } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index cd1fe7be87f..0ed4215aa8f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2979,6 +2979,14 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) } + // Why: renderer-initiated meta updates intentionally skip the renderer + // notifier (the renderer already applied them optimistically), but remote + // clients hold no optimistic copy and need the invalidation event. + notifyWorktreesChangedForRemoteClients(repoId: string): void { + this.invalidateResolvedWorktreeCache() + this.emitClientEvent({ type: 'worktreesChanged', repoId }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, From 6a176b89fc3844848cb6519e1f0cc57d60d005af Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:45:32 -0700 Subject: [PATCH 87/95] fix(source-control): keep push enabled for same-repo linked review with real upstream (#8202) * fix(source-control): keep push enabled for same-repo linked review with real upstream Push/Force Push (and Pull/Sync/Publish) were wrongly disabled when a branch had a genuine git upstream but an open linked PR whose push target had not yet hydrated. `hasUsableHostedReviewPushTarget` hard-returned false for any resolvable review link without a hydrated `pushTarget`, so `resolveHostedReviewActionUpstreamStatus` synthesized `hasUpstream:false` and the whole remote menu treated the branch as unpublished. A same-repo review's head IS the checked-out branch, so a real upstream already tracking that branch is that head and is safe to use before the resolver hydrates a target. Treat that upstream as usable; keep blocking the fork-head/cross-repo case (upstream tracks a differently-named head) and the no-upstream case until the resolver proves the real target. Provider-agnostic (GitHub PR / GitLab MR) and covers the SSH linked-review path. Adds regression tests to source-control-hosted-review-push-target.test.ts and a composed pipeline test in source-control-dropdown-items.test.ts. * docs(source-control): trim verbose comments on linked-review push-target fix Co-authored-by: Orca * refactor(source-control): use canonical gitRefTargetsBranchName for linked-review upstream match Replace the hand-rolled upstreamTracksBranch leaf-split with the shared gitRefTargetsBranchName primitive, which trims and rejects malformed remote-qualified refs. The remote stays unknown until the resolver hydrates the push target, so this remains a branch-leaf match; the strict remote+branch check takes over once the target is known. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../right-sidebar/SourceControl.tsx | 3 +- .../source-control-dropdown-items.test.ts | 80 +++++++++++++++- ...-control-hosted-review-push-target.test.ts | 92 ++++++++++++++++++- ...ource-control-hosted-review-push-target.ts | 15 ++- 4 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 22e3878848b..6ab53ec36ed 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1676,7 +1676,8 @@ function SourceControlInner(): React.JSX.Element { const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ pushTarget: activeWorktree?.pushTarget, upstreamStatus: remoteStatus, - hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink + hasResolvableHostedReviewPushTargetLink: hasResolvableReviewPushTargetLink, + branchName }) const hostedReviewStateForActions = resolveHostedReviewStateForActions({ hostedReviewState: hostedReview?.state ?? null, diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 44aeb8e7bbc..86af5bafdb7 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { resolveDropdownItems, type DropdownActionInputs } from './source-control-dropdown-items' +import { + resolveDropdownItems, + type DropdownActionInputs, + type DropdownItem +} from './source-control-dropdown-items' +import { + hasUsableHostedReviewPushTarget, + resolveHostedReviewActionUpstreamStatus +} from './source-control-hosted-review-push-target' // Why: a shared defaults object keeps each case row terse while making the // "this is the one knob that differs from the baseline" intent obvious. @@ -768,3 +776,73 @@ describe('resolveDropdownItems', () => { expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') }) }) + +// Why: PR #8196 — drive the real push-target resolution the component uses so +// the whole chain stays regression-proof. +describe('resolveDropdownItems with an unhydrated linked-review push target', () => { + function pipeline(args: { + branchName: string + upstreamStatus: DropdownActionInputs['upstreamStatus'] + }): Record { + const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ + // pushTarget intentionally omitted: the resolver has not hydrated it yet. + hasResolvableHostedReviewPushTargetLink: true, + branchName: args.branchName, + upstreamStatus: args.upstreamStatus + }) + const upstreamStatus = resolveHostedReviewActionUpstreamStatus({ + hasHostedReviewLink: true, + hasResolvableHostedReviewPushTargetLink: true, + hostedReviewState: 'open', + isHostedReviewStateLoading: false, + canUseHostedReviewPushTarget, + upstreamStatus: args.upstreamStatus + }) + const items = resolveDropdownItems( + inputs({ + stagedCount: 1, + hasMessage: true, + branchCommitsAhead: 7, + prState: 'open', + upstreamStatus, + canPushLinkedReviewWithoutUpstream: canUseHostedReviewPushTarget + }) + ) + return Object.fromEntries( + items.filter((e): e is DropdownItem => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + } + + it('enables Push and Force Push when the real upstream is the same-repo review head', () => { + const byKind = pipeline({ + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/mobile-resume-suspected-fixes', + ahead: 7, + behind: 2 + } + }) + expect(byKind.push.disabled).toBe(false) + expect(byKind.push.title).not.toBe('Linked review branch target is unavailable') + expect(byKind.force_push.disabled).toBe(false) + expect(byKind.pull.disabled).toBe(false) + expect(byKind.sync.disabled).toBe(false) + expect(byKind.publish.disabled).toBe(true) + }) + + it('still blocks Push when the real upstream is an unrelated fork/helper head', () => { + const byKind = pipeline({ + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/helper-branch', + ahead: 1, + behind: 0 + } + }) + expect(byKind.push.disabled).toBe(true) + expect(byKind.push.title).toBe('Linked review branch target is unavailable') + expect(byKind.force_push.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts index 8e732f68fff..b65a6f8e35d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.test.ts @@ -136,7 +136,12 @@ describe('hasPositiveHostedReviewNumberLink', () => { expect(hasPositiveHostedReviewNumberLink({ linkedBitbucketPR: 34 })).toBe(true) expect(hasPositiveHostedReviewNumberLink({ linkedAzureDevOpsPR: 56 })).toBe(true) expect(hasPositiveHostedReviewNumberLink({ linkedGiteaPR: 78 })).toBe(true) - expect(hasPositiveHostedReviewNumberLink({ linkedGitHubPR: 0, linkedGitLabMR: -1 })).toBe(false) + expect( + hasPositiveHostedReviewNumberLink({ + linkedGitHubPR: 0, + linkedGitLabMR: -1 + }) + ).toBe(false) expect(hasPositiveHostedReviewNumberLink({ linkedGitHubPR: Number.NaN })).toBe(false) expect(hasPositiveHostedReviewNumberLink({})).toBe(false) }) @@ -187,18 +192,33 @@ describe('hasUsableHostedReviewPushTarget', () => { expect( hasUsableHostedReviewPushTarget({ pushTarget: { remoteName: 'fork', branchName: 'feature' }, - upstreamStatus: { hasUpstream: true, upstreamName: 'fork/feature', ahead: 1, behind: 0 } + upstreamStatus: { + hasUpstream: true, + upstreamName: 'fork/feature', + ahead: 1, + behind: 0 + } }) ).toBe(true) expect( hasUsableHostedReviewPushTarget({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0, hasConfiguredPushTarget: true } + upstreamStatus: { + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + } }) ).toBe(true) expect( hasUsableHostedReviewPushTarget({ hasResolvableHostedReviewPushTargetLink: true, - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0, hasConfiguredPushTarget: true } + upstreamStatus: { + hasUpstream: false, + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + } }) ).toBe(false) expect( @@ -209,4 +229,68 @@ describe('hasUsableHostedReviewPushTarget', () => { ).toBe(false) expect(hasUsableHostedReviewPushTarget({ upstreamStatus: unrelatedUpstream })).toBe(false) }) + + it('treats a same-repo review upstream that already tracks the branch as usable', () => { + // Why: a same-repo review must not stay blocked while its push target is + // unhydrated — the real upstream already targets the review head. + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature/foo', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature/foo', + ahead: 7, + behind: 2 + } + }) + ).toBe(true) + }) + + it('keeps blocking a review whose upstream tracks an unrelated fork/helper head', () => { + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature', + upstreamStatus: unrelatedUpstream + }) + ).toBe(false) + }) + + it('keeps blocking a resolvable review with no real upstream', () => { + expect( + hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'feature', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }) + ).toBe(false) + }) +}) + +describe('resolveHostedReviewActionUpstreamStatus with a same-repo upstream', () => { + it('does not synthesize hasUpstream:false when the real upstream is the review head', () => { + const realUpstream = { + hasUpstream: true, + upstreamName: 'origin/mobile-resume-suspected-fixes', + ahead: 7, + behind: 2 + } + const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({ + hasResolvableHostedReviewPushTargetLink: true, + branchName: 'mobile-resume-suspected-fixes', + upstreamStatus: realUpstream + }) + expect(canUseHostedReviewPushTarget).toBe(true) + expect( + resolveHostedReviewActionUpstreamStatus({ + hasHostedReviewLink: true, + hasResolvableHostedReviewPushTargetLink: true, + hostedReviewState: 'open', + isHostedReviewStateLoading: false, + canUseHostedReviewPushTarget, + upstreamStatus: realUpstream + }) + ).toBe(realUpstream) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts index 9dd07b7e27b..99be32e886e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-push-target.ts @@ -1,11 +1,13 @@ import type { GitPushTarget, GitUpstreamStatus } from '../../../../shared/types' import type { HostedReviewState } from '../../../../shared/hosted-review' import { getPublishTargetDisplayName } from '../../../../shared/git-publish-target-status' +import { gitRefTargetsBranchName } from '../../../../shared/git-remote-branch-name' export function hasUsableHostedReviewPushTarget(args: { pushTarget?: GitPushTarget upstreamStatus?: GitUpstreamStatus hasResolvableHostedReviewPushTargetLink?: boolean + branchName?: string }): boolean { if (args.pushTarget) { return ( @@ -14,9 +16,16 @@ export function hasUsableHostedReviewPushTarget(args: { ) } if (args.hasResolvableHostedReviewPushTargetLink) { - // Why: a bare branch-config flag does not identify which review head it will - // push to; resolver-backed links need hydrated target metadata to prove it. - return false + // Why: a same-repo review's head is the checked-out branch, so a real + // upstream tracking it is safe before the resolver hydrates. Fork/cross-repo + // heads differ, so a mismatched or missing upstream stays blocked. The + // review's remote is unknown pre-hydration, so this can only match the + // branch leaf; the strict remote+branch check above takes over once known. + return ( + args.upstreamStatus?.hasUpstream === true && + args.branchName !== undefined && + gitRefTargetsBranchName(args.upstreamStatus.upstreamName, args.branchName) + ) } return args.upstreamStatus?.hasConfiguredPushTarget === true } From 5b1fcb0ff09c5db6076a3e41d08e6e192cf13bc4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:56:33 -0700 Subject: [PATCH 88/95] Fix task workspace handoff after switching workspaces (#8226) Co-authored-by: Orca --- .../src/lib/worktree-creation-flow.test.ts | 59 +++++++++++++++++++ .../src/lib/worktree-creation-flow.ts | 17 ++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 1ceebd2093f..f614c679c7e 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -504,6 +504,65 @@ describe('staged background worktree creation', () => { }) }) + it('reveals the completed workspace after the user switches to another workspace', async () => { + let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void + store.createWorktree.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve + }) + ) + + const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { + revealCreationSurface: false + }) + + expect(started).toBe(true) + await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledTimes(1)) + // Why: selecting a real workspace clears only the pending surface pointer; + // completion should still finish the task-launch handoff once it is ready. + store.activePendingCreationId = null + resolveCreate({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + await flushAsyncWorktreeCreation() + + expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { + sidebarRevealBehavior: 'auto' + }) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { + cleanupVm: false + }) + }) + + it('does not reveal a workspace cancelled during post-create trust preflight', async () => { + let resolveTrust!: () => void + const markTrusted = vi.fn( + () => + new Promise((resolve) => { + resolveTrust = resolve + }) + ) + globalThis.window = { api: { agentTrust: { markTrusted } } } as never + store.repos = [{ id: 'repo-1', connectionId: null }] + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ agent: 'codex' }), + { revealCreationSurface: false } + ) + + expect(started).toBe(true) + await vi.waitFor(() => expect(markTrusted).toHaveBeenCalledTimes(1)) + delete store.pendingWorktreeCreations['creation-1'] + store.activePendingCreationId = null + resolveTrust() + await vi.waitFor(() => expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledTimes(1)) + + expect(activateAndRevealWorktree).not.toHaveBeenCalled() + }) + // 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. diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 9af1dc61ebe..6bdac108c23 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -214,13 +214,20 @@ async function executeWorktreeCreation( await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId) } - // `createWorktree` already inserted the real worktree row. Whether we steal - // the view depends on whether the user is still watching this creation. - const stillActive = isPendingCreationSurfaceVisible(creationId) + // `createWorktree` already inserted the real worktree row. Leaving for an app + // view keeps the create in the background, while selecting another workspace + // means the user still expects this task-launch handoff when it becomes ready; + // the entry guard prevents a late trust preflight from reviving a cancelled create. + const completionState = useAppStore.getState() + const shouldActivateOnCompletion = + completionState.pendingWorktreeCreations[creationId] !== undefined && + (isPendingCreationSurfaceVisible(creationId) || + (completionState.activeView === 'terminal' && + completionState.activePendingCreationId === null)) let activation: ActivateAndRevealResult | false = false let primaryTabId: string | null - if (stillActive) { + if (shouldActivateOnCompletion) { activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), @@ -254,7 +261,7 @@ async function executeWorktreeCreation( startup: preparedRequest.startupPlan }) } - if (stillActive && !preparedRequest.suppressTerminalFocusOnCompletion) { + if (shouldActivateOnCompletion && !preparedRequest.suppressTerminalFocusOnCompletion) { queueNewWorkspaceTerminalFocus(worktree.id, activation) } From 026516f32360f93cafc65843cd95da032860d34d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:04:38 -0700 Subject: [PATCH 89/95] the-sandbox-flag-is-2 (#8236) * Fix mobile source control drawer overflow and branch-compare state loss - Render BottomDrawer in a native Modal so it covers the full viewport even when mounted inside a ScrollView. - Move the conflict/Abort row onto its own line so it never overflows the branch card, and enlarge the Abort hit target. - Show the committed-on-branch footer even when the changed-files SectionList has no sections, since RN skips ListFooterComponent for empty sections. - Stop branch-compare state from collapsing to idle/error on transient base-ref resolution failures when a ready result should be preserved. * Add mobile source control drawer reload screenshot Attaches an evidence screenshot for the mobile source control drawer overflow / branch-compare state loss fix. * Remove stray temp screenshot file Accidentally committed debug artifact from mobile source control drawer work; not needed in the repo. --- mobile/src/components/BottomDrawer.tsx | 188 +++++++++--------- .../MobileSourceControlBranchCard.tsx | 37 ++-- .../MobileSourceControlContent.tsx | 52 +++-- .../mobile-source-control-styles.ts | 27 ++- .../use-mobile-source-control-loaders.ts | 21 +- .../use-mobile-source-control-state.ts | 2 + 6 files changed, 195 insertions(+), 132 deletions(-) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 0f83bbf5f54..4e24e8c2e06 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -7,7 +7,8 @@ import { useWindowDimensions, ScrollView, Keyboard, - BackHandler + BackHandler, + Modal } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' @@ -264,96 +265,105 @@ function MountedBottomDrawer({ return { opacity: progress.value * dragFade } }) + // Why: rendering through a full-screen Modal lifts the sheet into its own + // native window so it always covers the viewport — even when the drawer is + // mounted deep inside a ScrollView, where a plain absolute overlay anchors to + // the scrolled content and clips the sheet. The Modal stays mounted (visible) + // for the whole life of MountedBottomDrawer so the reanimated exit animation + // runs before the parent unmounts us; show/hide is driven by `progress`, so + // animationType stays "none". onRequestClose handles the Android back button. return ( - - - - - - - - - {!contentScrollable ? ( - <> - - - - - - {children} - - ) : dragContentToDismiss ? ( - <> - - - - - - - - - - {children} - - - - - - ) : ( - <> - - - - - - - {children} - - - )} - + + + + + - - - + + + + {!contentScrollable ? ( + <> + + + + + + {children} + + ) : dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} + + + + + + ) } diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx index 04aae01b0bc..9be5430c807 100644 --- a/mobile/src/source-control/MobileSourceControlBranchCard.tsx +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -55,23 +55,28 @@ export function MobileSourceControlBranchCard({ {unstagedCount} changed {stagedCount} staged {branchCount > 0 ? {branchCount} on branch : null} - {showConflict ? ( - - {conflictOperation} - {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={conflictBusy} - onPress={() => onAbortConflict(conflictOperation)} - > - - {mobileConflictAbortLabel(conflictOperation, conflictAborting)} - - - ) : null} - - ) : null} + {/* Own row so Abort never overflows past the card when counts are long. */} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [ + styles.abortButton, + conflictBusy && styles.abortButtonDisabled, + pressed && !conflictBusy && styles.abortPressed + ]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} {prChip ? : null} ) diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index a28504a5c53..885d8bf0c4d 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,4 +1,12 @@ -import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' +import { + ActivityIndicator, + Pressable, + ScrollView, + SectionList, + Text, + TextInput, + View +} from 'react-native' import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' @@ -13,7 +21,8 @@ type Props = { state: MobileSourceControlState } -// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. +// Changes tab: local file changes only — uncommitted (staged/unstaged) plus +// committed-on-branch vs base. PR conflicts and push status live elsewhere. export function MobileSourceControlContent({ state }: Props) { const { insets, @@ -49,6 +58,21 @@ export function MobileSourceControlContent({ state }: Props) { const shouldShowGenerateButton = stagedCount > 0 || generatingMessage const createPrHeroActive = createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst + const branchCompareFooter = ( + + ) return ( <> @@ -126,9 +150,15 @@ export function MobileSourceControlContent({ state }: Props) { {!hasVisibleChanges ? ( - No Changes + No local changes Working tree is clean. + ) : sections.length === 0 ? ( + // Why: RN SectionList with empty `sections` often skips ListFooterComponent, + // which hid "Committed on Branch" when only branch files remain. + + {branchCompareFooter} + ) : ( {section.data.length} )} - ListFooterComponent={ - - } + ListFooterComponent={branchCompareFooter} stickySectionHeadersEnabled={false} contentContainerStyle={styles.listContent} /> diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index ea766731840..d8dc20a1469 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -92,6 +92,7 @@ const baseStyles = StyleSheet.create({ }, countRow: { flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.sm }, @@ -99,29 +100,43 @@ const baseStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Separate line under counts — keeps Abort inside the card on narrow phones. conflictRow: { flexDirection: 'row', + flexWrap: 'wrap', alignItems: 'center', - gap: spacing.sm + gap: spacing.sm, + marginTop: spacing.sm, + alignSelf: 'flex-start', + maxWidth: '100%' }, conflictText: { color: colors.statusAmber, fontSize: typography.metaSize, textTransform: 'capitalize' }, + // Match bulk-action hit target so Abort reads as a real control, not a chip. abortButton: { - paddingHorizontal: spacing.sm, - paddingVertical: 2, + minHeight: 32, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, borderRadius: radii.button, borderWidth: 1, - borderColor: colors.statusAmber + borderColor: colors.statusAmber, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0 }, abortPressed: { - backgroundColor: colors.bgRaised + opacity: 0.75 + }, + abortButtonDisabled: { + opacity: 0.45 }, abortText: { color: colors.statusAmber, - fontSize: typography.metaSize, + fontSize: typography.bodySize, fontWeight: '600', textTransform: 'capitalize' }, diff --git a/mobile/src/source-control/use-mobile-source-control-loaders.ts b/mobile/src/source-control/use-mobile-source-control-loaders.ts index 5a373ea5025..86055d21d3f 100644 --- a/mobile/src/source-control/use-mobile-source-control-loaders.ts +++ b/mobile/src/source-control/use-mobile-source-control-loaders.ts @@ -104,8 +104,18 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr return false } if (!baseRef) { - setBranchCompareState({ kind: 'idle' }) - return true + // Why: wiping a prior ready compare to idle makes Changes say "No + // Changes" even when commits still exist (e.g. after abort-merge refresh). + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { + kind: 'error', + message: 'Unable to resolve the base branch for comparison.' + } + }) + return false } const response = await client.sendRequest('git.branchCompare', { worktree: `id:${worktreeId}`, @@ -116,7 +126,12 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr } if (!response.ok) { if (isMobileGitUnavailable(response.error?.code, response.error?.message)) { - setBranchCompareState({ kind: 'idle' }) + setBranchCompareState((prev) => { + if (options?.preserveReadyOnFailure && prev.kind === 'ready') { + return prev + } + return { kind: 'idle' } + }) return false } throw new Error(response.error?.message || 'Unable to load committed changes') diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index c9a8ca406b0..e50525f3c62 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -15,6 +15,7 @@ import { canOpenMobileBranchCompareDiff, formatMobileBranchCompareSummary } from './mobile-branch-compare' + import { buildMobileSourceControlSections, countStagedEntries, @@ -140,6 +141,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara })), [branchCompareCanOpen, branchCompareSection] ) + // Local changes only: dirty files + committed file diffs vs base (not PR/push). const shouldShowBranchCompareSection = branchEntries.length > 0 || branchCompareState.kind === 'loading' || From 66cad529a2abb66a81a51191fa57d98cb68760ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:06:56 -0700 Subject: [PATCH 90/95] Improve chinese translation 2 (#8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve zh/ja/ko/es translations for missing and untranslated i18n strin - Translates several previously English-only entries (locked workspace, host removal, subagent labels, org/profile switcher dialogs) that were left untranslated in each locale file - Fixes zh/ja/ko/es JSON EOF newline formatting * Improve zh/ja/ko/es translation quality and terminology consistency - Fix mistranslated/untranslated strings (English leftovers in es/ja, incorrect "role"→"forbidden" wording in ko) - Standardize terminology: 子代理→子智能体, 中立→中性 (zh); 하위 에이전트→서브에이전트 (ko); align count-phrase word order with natural ko/ko phrasing (e.g. "N개 X" → "X N개") --- src/renderer/src/i18n/locales/es.json | 236 +++++++++++++------------- src/renderer/src/i18n/locales/ja.json | 122 ++++++------- src/renderer/src/i18n/locales/ko.json | 106 ++++++------ src/renderer/src/i18n/locales/zh.json | 48 +++--- 4 files changed, 256 insertions(+), 256 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 36873744e79..ffffbfb77bd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -251,8 +251,8 @@ "6d3318e813": "No se pudieron importar repositorios", "3be0f7df04": "No se puede abrir la carpeta en el host seleccionado", "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable.", - "2dcd706774": "Project also exists in another profile", - "presenceProfileOverflow": "{{names}} +{{count}} more" + "2dcd706774": "El proyecto también existe en otro perfil", + "presenceProfileOverflow": "{{names}} +{{count}} más" }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -270,20 +270,20 @@ }, "orca": { "profiles": { - "612f7f6861": "Failed to create profile", - "319d7cf39b": "Cloud profile created", - "d6e764e7db": "Reconnect this profile", - "f0c9e11a6d": "Failed to create cloud profile", - "8b8fa73174": "Orca Cloud sign-in is not configured", - "33290e88ed": "Failed to connect profile", - "9fcb07a796": "Profile connected", - "2f6c78a039": "Failed to refresh profile auth", - "a37b5e6d37": "Signed out of profile", - "83600521e7": "Failed to sign out", - "76deec8f58": "Failed to switch organization", - "7d4bc516ee": "Failed to switch profile", - "f518e89aa5": "Project already exists in that profile", - "f03ae7f27b": "Failed to transfer project" + "612f7f6861": "Error al crear perfil", + "319d7cf39b": "Perfil en la nube creado", + "d6e764e7db": "Reconectar este perfil", + "f0c9e11a6d": "Error al crear perfil en la nube", + "8b8fa73174": "El inicio de sesión de Orca Cloud no está configurado", + "33290e88ed": "Error al conectar perfil", + "9fcb07a796": "Perfil conectado", + "2f6c78a039": "Error al actualizar autenticación del perfil", + "a37b5e6d37": "Sesión cerrada del perfil", + "83600521e7": "Error al cerrar sesión", + "76deec8f58": "Error al cambiar organización", + "7d4bc516ee": "Error al cambiar perfil", + "f518e89aa5": "El proyecto ya existe en ese perfil", + "f03ae7f27b": "Error al transferir proyecto" } } } @@ -4309,8 +4309,8 @@ "ead7b8ee15": "Tiene archivos modificados. Usa Forzar eliminación para eliminarlo de todos modos.", "905fc8efac": "Git ya eliminó este espacio de trabajo. Usa Forzar eliminación para borrarlo de Orca.", "0899ebdb28": "Git ya olvidó este espacio de trabajo, pero su directorio todavía está en disco. Usa Forzar eliminación para eliminar el directorio huérfano.", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "Este espacio de trabajo está bloqueado por Git. Ejecute git worktree unlock desde su repositorio, luego intente la eliminación de nuevo.", + "lockedReason": "Este espacio de trabajo está bloqueado por Git. Git reportó: {{value0}}. Ejecute git worktree unlock desde su repositorio, luego intente la eliminación de nuevo." } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "Cancelar", "7a8b9c0d1e": "Abrir ajustes", "8b9c0d1e2f": "Quitar host", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "No se pudieron eliminar {{count}} espacios de trabajo de este anfitrión. El anfitrión se conservó para que pueda reintentar.", "oneWorkspace": "1 espacio de trabajo", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} espacios de trabajo", + "hostHasWorkspacesDefault": "Elimina {{value0}} y sus credenciales de esta computadora. Sus {{value1}} permanecen en Orca — los archivos remotos no se tocan.", + "alsoDeleteRemote": "También eliminar estos {{value0}} en {{value1}}", + "alsoForgetLocal": "También eliminar estos {{value0}} de Orca", + "advanced": "Avanzado", "alsoDeleteRemoteHint": "Elimina permanentemente los espacios de trabajo Git remotos y sus ramas. Esta acción no se puede deshacer.", "alsoForgetLocalHint": "Solo limpia de Orca. Los archivos, espacios de trabajo y ramas remotas permanecen intactos." }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Error de reconexión", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", - "title": "Delete “{{name}}”?", + "forgetBody": "Elimina este espacio de trabajo solo de Orca. Los archivos, el worktree de Git y las ramas en {{host}} permanecen intactos.", + "title": "¿Eliminar “{{name}}”?", "disconnectedBody": "El host SSH de este espacio de trabajo no está conectado. Reconéctalo para eliminarlo también en el remoto, o elimínalo solo de Orca.", - "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "ghostBody": "{{host}} ya no es un host SSH guardado, por lo que este espacio de trabajo ya no está conectado a un host activo. Solo se puede eliminar de Orca — los archivos y ramas en el remoto permanecen intactos.", + "cancel": "Cancelar", "forget": "Eliminar de Orca", "reconnectAndDelete": "Reconectar y eliminar" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "Se terminaron {{value0}} sesiones.", "63520148e2": "La sesión {{value0}} se negó a salir.", "cc0a26cb14": "{{value0}} sesiones se negaron a salir.", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "Pestañas de terminal ausentes: {{value0}}/{{value1}}. Intentos de cierre fallidos: {{value2}}. Solicitudes de apagado PTY exactas aceptadas: {{value3}}; fallidas: {{value4}}.", "2e57c1a940": "El resultado del apagado del daemon no está verificado porque su solicitud de gestión falló.", "993af6052c": "La gestión del daemon reportó salida: {{value0}}/{{value1}}; aún presente antes de la limpieza exacta: {{value2}}.", "1f0d8ac762": "La limpieza del terminal terminó con errores.", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "Saltar al panel original", "jumpToWorktree": "Saltar al worktree", "recoverableBadge": "No guardada", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 subagente", + "subagentCountPlural": "{{value0}} subagentes" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "No hay ningún worktree activo disponible." }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", + "subagentsCount": "Subagentes ({{value0}})", "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "viewLog": "Ver registro" } } }, @@ -10483,8 +10483,8 @@ "fb5f28330e": "Mostrar en la barra lateral", "c669abcf8f": "Ocultar de la barra lateral", "e1c7b4a92d": "Configúralo en Ajustes > Móvil.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", + "a4f8c2d91e": "Eliminar Orca Mobile de la barra lateral izquierda", + "b7e3d1c84a": "Mostrar Orca Mobile en la barra lateral izquierda", "f3d8e5b71a": "Vuelve a añadir el acceso directo a la barra lateral." }, "PhoneCarousel": { @@ -12685,149 +12685,149 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "Cloud sign-in unavailable" + "unavailable": "Inicio de sesión en la nube no disponible" }, - "account": "Account", - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", + "account": "Cuenta", + "reconnect": "Reconectar perfil", + "connect": "Conectar perfil", + "organization": "Organización", "create": { "profile": { "for": { - "org": "Create profile for org" + "org": "Crear perfil para organización" } } }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", + "signout": "Cerrar sesión", + "16e3681072": "Nuevo perfil local", + "e3b91a3d90": "Crear un perfil vacío para proyectos y worktrees separados.", + "f322e1f4d6": "Nombre del perfil", + "cfa59f8ad1": "Crear y cambiar", "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile", + "4815f7d163": "Cambiar perfil", + "d00d853e2a": "Gestionar perfiles", + "c106c674fe": "Nuevo perfil local", "org": { - "members": "Organization members" + "members": "Miembros de la organización" } }, "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects.", - "projectCountSingular": "{{count}} project", - "projectCountPlural": "{{count}} projects" + "04e7bd2a23": "Transferir", + "128c7dfe64": "Copiar a", + "df8b7d876b": "Mover a", + "9aa26347b3": "Proyecto movido", + "816ce624b6": "Proyecto copiado", + "2c45bda8d3": "Gestionar perfiles", + "2db945e4a0": "Copiar o mover proyectos del perfil activo a otro perfil local.", + "8668cb2946": "No hay proyectos en este perfil.", + "93034915ab": "Cree otro perfil antes de copiar proyectos.", + "projectCountSingular": "{{count}} proyecto", + "projectCountPlural": "{{count}} proyectos" }, "project": { "transfer": { "confirm": { "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" + "title": "¿Mover proyecto?", + "description": "Mover {{repoName}} a {{targetName}}. Orca lo elimina de {{activeProfileName}}, mantiene los archivos en su lugar y relanza en {{targetName}}.", + "action": "Mover proyecto" }, "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" + "title": "¿Copiar proyecto?", + "description": "Copiar {{repoName}} a {{targetName}}. Ambos perfiles apuntarán a los mismos archivos con metadatos de Orca separados.", + "action": "Copiar proyecto" }, "live": { - "work": "Live work in this project" + "work": "Trabajo activo en este proyecto" }, - "cancel": "Cancel", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" + "cancel": "Cancelar", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } } }, "signout": { "confirm": { - "title": "Sign out?", - "description": "Sign out of {{profileName}} and keep its projects, worktrees, and local metadata on this device.", - "cancel": "Cancel", - "action": "Sign out" + "title": "¿Cerrar sesión?", + "description": "Cerrar sesión de {{profileName}} y mantener sus proyectos, worktrees y metadatos locales en este dispositivo.", + "cancel": "Cancelar", + "action": "Cerrar sesión" } }, "switch": { "confirm": { - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", + "target": "el perfil seleccionado", + "title": "¿Cambiar perfiles?", + "description": "Cambiar a {{targetName}} relanzará Orca y recargará el espacio de trabajo de {{activeProfileName}}.", "live": { - "work": "Live work in this profile" + "work": "Trabajo activo en este perfil" }, - "cancel": "Cancel", - "switch": "Switch profile", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" + "cancel": "Cancelar", + "switch": "Cambiar perfil", + "terminalSingular": "{{count}} pestaña de terminal activa", + "terminalPlural": "{{count}} pestañas de terminal activas", + "agentSingular": "{{count}} agente activo", + "agentPlural": "{{count}} agentes activos", + "browserSingular": "{{count}} espacio de trabajo del navegador", + "browserPlural": "{{count}} espacios de trabajo del navegador" } }, "org": { "members": { "role": { - "label": "Role", - "owner": "Owner", - "admin": "Admin", - "member": "Member" + "label": "Rol", + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" }, "not": { "signed": { - "in": "They haven't signed in to Orca yet." + "in": "Aún no han iniciado sesión en Orca." } }, "remove": { - "confirm": "Remove", - "cancel": "Cancel", - "label": "Remove teammate" + "confirm": "Eliminar", + "cancel": "Cancelar", + "label": "Eliminar compañero" }, - "title": "Organization members", + "title": "Miembros de la organización", "load": { - "error": "Couldn't load organization members." + "error": "No se pudieron cargar los miembros de la organización." }, - "retry": "Try again", + "retry": "Intentar de nuevo", "pending": { - "title": "Pending invites", - "revoke": "Revoke" + "title": "Invitaciones pendientes", + "revoke": "Revocar" }, "invite": { - "title": "Invite", + "title": "Invitar", "placeholder": "teammate@example.com", - "role": "Invite role", - "submit": "Send" + "role": "Rol de invitación", + "submit": "Enviar" }, "error": { - "forbidden": "Your role can't do that.", - "conflict": "Already a member or invited.", + "forbidden": "Tu rol no puede hacer eso.", + "conflict": "Ya es miembro o fue invitado.", "remove": { - "self": "You can't remove yourself." + "self": "No puedes eliminarte a ti mismo." }, "own": { - "role": "You can't change your own role." + "role": "No puedes cambiar tu propio rol." }, "not": { - "found": "That teammate is no longer in this organization." + "found": "Ese compañero ya no está en esta organización." }, - "reconnect": "Reconnect your profile to manage members.", - "unconfigured": "Organization management is unavailable in this build.", - "failed": "Something went wrong. Please try again." + "reconnect": "Reconecta tu perfil para gestionar miembros.", + "unconfigured": "La gestión de organización no está disponible en esta compilación.", + "failed": "Algo salió mal. Por favor, intenta de nuevo." }, "subtitle": { - "named": "People in {{orgName}} who can collaborate on Orca.", - "default": "People in your organization who can collaborate on Orca." + "named": "Personas en {{orgName}} que pueden colaborar en Orca.", + "default": "Personas en tu organización que pueden colaborar en Orca." } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c4319b90e53..f752cfadf71 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4290,8 +4290,8 @@ "ead7b8ee15": "ファイルが変更されました。とにかく削除するには、強制削除を使用してください。", "905fc8efac": "Git はこのワークスペースをすでに削除しています。 Orca から削除するには、強制削除を使用します。", "0899ebdb28": "Git はすでにこのワークスペースを忘れていますが、そのディレクトリはまだディスク上にあります。孤立したディレクトリを削除するには、強制削除を使用します。", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "このワークスペースはGitによってロックされています。リポジトリでgit worktree unlock を実行してから、削除を再試行してください。", + "lockedReason": "このワークスペースはGitによってロックされています。Gitレポート: {{value0}}。リポジトリでgit worktree unlock を実行してから、削除を再試行してください。" } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "キャンセル", "7a8b9c0d1e": "Open settings", "8b9c0d1e2f": "ホストを削除", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "このホストの {{count}} 個のワークスペースを削除できませんでした。ホストは保持されたので、再試行できます。", "oneWorkspace": "1つのワークスペース", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} 個のワークスペース", + "hostHasWorkspacesDefault": "このコンピュータから {{value0}} とその認証情報を削除します。{{value1}} は Orca に残ります — リモートファイルは変更されません。", + "alsoDeleteRemote": "また、{{value1}} のこれらの {{value0}} も削除", + "alsoForgetLocal": "また、Orca からこれらの {{value0}} も削除", + "advanced": "詳細設定", "alsoDeleteRemoteHint": "リモートGitワークスペースとそのブランチを完全に削除します。この操作は取り消せません。", "alsoForgetLocalHint": "Orcaからのみクリアします。リモートファイル、ワークスペース、ブランチはそのまま残ります。" }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "再接続に失敗しました", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", + "forgetBody": "このワークスペースをOrcaからのみ削除します。ファイル、Gitワークツリー、{{host}}のブランチはそのまま残ります。", "title": "Delete “{{name}}”?", "disconnectedBody": "このワークスペースのSSHホストが接続されていません。再接続してリモートでも削除するか、Orcaからのみ削除してください。", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "cancel": "キャンセル", "forget": "Orcaから削除", "reconnectAndDelete": "再接続して削除" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "{{value0}} 個のセッションを終了しました。", "63520148e2": "{{value0}} 個のセッションが終了を拒否しました。", "cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "ターミナルタブがありません: {{value0}}/{{value1}}。閉じる試行失敗: {{value2}}。正確なPTYシャットダウン要求が受け入れられました: {{value3}}。失敗: {{value4}}。", "2e57c1a940": "管理リクエストが失敗したため、デーモンのシャットダウン結果が検証されていません。", "993af6052c": "デーモン管理が終了を報告:{{value0}}/{{value1}};正確なクリーンアップ前にまだ存在:{{value2}}。", "1f0d8ac762": "ターミナルクリーンアップがエラーで完了しました。", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "元のペインにジャンプ", "jumpToWorktree": "ワークツリーにジャンプ", "recoverableBadge": "未保存", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 件のサブエージェント", + "subagentCountPlural": "{{value0}} 件のサブエージェント" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "利用可能なアクティブなワークツリーはありません。" }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "サブエージェント ({{value0}})", + "messageCount": "{{value0}}件のメッセージ", + "viewLog": "ログを表示" } } }, @@ -10483,9 +10483,9 @@ "fb5f28330e": "サイドバーに表示", "c669abcf8f": "サイドバーから隠す", "e1c7b4a92d": "Configure in Settings > Mobile.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "a4f8c2d91e": "左サイドバーからOrca Mobileを削除", + "b7e3d1c84a": "左サイドバーにOrca Mobileを表示", + "f3d8e5b71a": "ショートカットを左サイドバーに再度追加します。" }, "PhoneCarousel": { "96d651cb87": "Terminal セッション", @@ -12685,7 +12685,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "クラウド サインインは利用できません" + "unavailable": "クラウドログインを利用できません" }, "reconnect": "プロファイルを再接続", "connect": "プロファイルを接続", @@ -12693,7 +12693,7 @@ "create": { "profile": { "for": { - "org": "組織用のプロファイルを作成" + "org": "組織用のプロフィールを作成" } } }, @@ -12708,7 +12708,7 @@ "c106c674fe": "新しいローカル プロファイル", "account": "アカウント", "org": { - "members": "組織のメンバー" + "members": "組織メンバー" } }, "management": { @@ -12729,105 +12729,105 @@ "confirm": { "move": { "title": "プロジェクトを移動しますか?", - "description": "{{repoName}} を {{targetName}} に移動します。Orca は {{activeProfileName}} から削除し、ファイルはそのまま残して {{targetName}} で再起動します。", + "description": "{{repoName}}を{{targetName}}に移動します。Orcaは{{activeProfileName}}から削除し、ファイルはそのまま保持し、{{targetName}}に再起動します。", "action": "プロジェクトを移動" }, "copy": { "title": "プロジェクトをコピーしますか?", - "description": "{{repoName}} を {{targetName}} にコピーします。両方のプロファイルは同じファイルを参照し、Orca のメタデータは別々に保持されます。", + "description": "{{repoName}}を{{targetName}}にコピーします。両方のプロフィールが同じファイルを別のOrcaメタデータで参照します。", "action": "プロジェクトをコピー" }, "live": { - "work": "このプロジェクトで実行中の作業" + "work": "このプロジェクトのアクティブな作業" }, "cancel": "キャンセル", - "terminalSingular": "{{count}} 個の実行中ターミナル タブ", - "terminalPlural": "{{count}} 個の実行中ターミナル タブ", - "agentSingular": "{{count}} 個のアクティブなエージェント", - "agentPlural": "{{count}} 個のアクティブなエージェント", - "browserSingular": "{{count}} 個のブラウザ ワークスペース", - "browserPlural": "{{count}} 個のブラウザ ワークスペース" + "terminalSingular": "{{count}}個のアクティブなターミナルタブ", + "terminalPlural": "{{count}}個のアクティブなターミナルタブ", + "agentSingular": "{{count}}個のアクティブなエージェント", + "agentPlural": "{{count}}個のアクティブなエージェント", + "browserSingular": "{{count}}個のブラウザワークスペース", + "browserPlural": "{{count}}個のブラウザワークスペース" } } }, "signout": { "confirm": { - "title": "サインアウトしますか?", - "description": "{{profileName}} からサインアウトし、そのプロジェクト、ワークツリー、ローカル メタデータはこのデバイスに保持します。", + "title": "ログアウトしますか?", + "description": "{{profileName}}からログアウトし、プロジェクト、ワークツリー、ローカルメタデータをこのデバイスに保持します。", "cancel": "キャンセル", - "action": "サインアウト" + "action": "ログアウト" } }, "switch": { "confirm": { - "target": "選択したプロファイル", - "title": "プロファイルを切り替えますか?", - "description": "{{targetName}} に切り替えると、Orca が再起動し、{{activeProfileName}} のワークスペースが再読み込みされます。", + "target": "選択されたプロフィール", + "title": "プロフィールを切り替えますか?", + "description": "{{targetName}}に切り替えると、Orcaが再起動され、{{activeProfileName}}のワークスペースが再読み込みされます。", "live": { - "work": "このプロファイルで実行中の作業" + "work": "このプロフィールのアクティブな作業" }, "cancel": "キャンセル", - "switch": "プロファイルを切り替え", - "terminalSingular": "{{count}} 個の実行中ターミナル タブ", - "terminalPlural": "{{count}} 個の実行中ターミナル タブ", - "agentSingular": "{{count}} 個のアクティブなエージェント", - "agentPlural": "{{count}} 個のアクティブなエージェント", - "browserSingular": "{{count}} 個のブラウザ ワークスペース", - "browserPlural": "{{count}} 個のブラウザ ワークスペース" + "switch": "プロフィールを切替", + "terminalSingular": "{{count}}個のアクティブなターミナルタブ", + "terminalPlural": "{{count}}個のアクティブなターミナルタブ", + "agentSingular": "{{count}}個のアクティブなエージェント", + "agentPlural": "{{count}}個のアクティブなエージェント", + "browserSingular": "{{count}}個のブラウザワークスペース", + "browserPlural": "{{count}}個のブラウザワークスペース" } }, "org": { "members": { "role": { - "label": "ロール", + "label": "役割", "owner": "オーナー", "admin": "管理者", "member": "メンバー" }, "not": { "signed": { - "in": "まだ Orca にサインインしていません。" + "in": "まだOrcaにサインインしていません。" } }, "remove": { "confirm": "削除", "cancel": "キャンセル", - "label": "メンバーを削除" + "label": "チームメンバーを削除" }, - "title": "組織のメンバー", + "title": "組織メンバー", "load": { - "error": "組織のメンバーを読み込めませんでした。" + "error": "組織メンバーを読み込めませんでした。" }, "retry": "再試行", "pending": { "title": "保留中の招待", - "revoke": "取り消す" + "revoke": "取り消し" }, "invite": { "title": "招待", "placeholder": "teammate@example.com", - "role": "招待するロール", + "role": "招待役割", "submit": "送信" }, "error": { - "forbidden": "あなたのロールではこの操作を実行できません。", - "conflict": "すでにメンバーまたは招待済みです。", + "forbidden": "この役割では実行できません。", + "conflict": "既にメンバーまたは招待済みです。", "remove": { "self": "自分自身を削除することはできません。" }, "own": { - "role": "自分のロールは変更できません。" + "role": "自分の役割を変更することはできません。" }, "not": { - "found": "そのメンバーはこの組織に存在しません。" + "found": "そのチームメンバーはこの組織にいません。" }, - "reconnect": "メンバーを管理するにはプロファイルを再接続してください。", - "unconfigured": "このビルドでは組織の管理を利用できません。", + "reconnect": "メンバーを管理するには、プロフィールを再接続してください。", + "unconfigured": "このビルドでは組織管理が利用できません。", "failed": "問題が発生しました。もう一度お試しください。" }, "subtitle": { - "named": "{{orgName}} で Orca を共同利用できるメンバーです。", - "default": "組織内で Orca を共同利用できるメンバーです。" + "named": "{{orgName}}でOrcaに協力できる人々。", + "default": "組織でOrcaに協力できる人々。" } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d44092ab6a5..d9c32e81f71 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4290,8 +4290,8 @@ "ead7b8ee15": "파일이 변경되었습니다. 어쨌든 삭제하려면 강제 삭제를 사용하세요.", "905fc8efac": "Git은 이미 이 워크스페이스를 제거했습니다. 강제 삭제를 사용하여 Orca에서 삭제합니다.", "0899ebdb28": "Git은 이미 이 워크스페이스를 잊어버렸지만 해당 디렉토리는 여전히 디스크에 있습니다. 강제 삭제를 사용하여 분리된 디렉터리를 제거합니다.", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "이 워크트리는 Git에 의해 잠겨 있습니다. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요.", + "lockedReason": "이 워크트리는 Git에 의해 잠겨 있습니다. Git 보고: {{value0}}. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요." } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "취소", "7a8b9c0d1e": "설정 열기", "8b9c0d1e2f": "호스트 제거", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "이 호스트에서 워크트리 {{count}}개를 제거할 수 없었습니다. 재시도할 수 있도록 호스트가 유지되었습니다.", "oneWorkspace": "워크트리 1개", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "워크트리 {{count}}개", + "hostHasWorkspacesDefault": "이 컴퓨터에서 {{value0}}과(와) 해당 자격 증명을 제거합니다. {{value1}}은(는) Orca에 남아 있습니다 — 원격 파일은 변경되지 않습니다.", + "alsoDeleteRemote": "{{value1}}에서 이러한 {{value0}}도 삭제", + "alsoForgetLocal": "Orca에서 이러한 {{value0}}도 제거", + "advanced": "고급", "alsoDeleteRemoteHint": "원격 Git 워크트리와 해당 브랜치를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "alsoForgetLocalHint": "Orca에서만 제거합니다. 원격 파일, 워크트리, 브랜치는 그대로 남아 있습니다." }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "재연결 실패", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", + "forgetBody": "이 작업 공간을 Orca에서만 제거합니다. 파일, Git 워크트리, {{host}}의 브랜치는 그대로 유지됩니다.", "title": "Delete “{{name}}”?", "disconnectedBody": "이 워크트리의 SSH 호스트가 연결되어 있지 않습니다. 재연결하여 원격에서도 삭제하거나 Orca에서만 제거하세요.", "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "cancel": "취소", "forget": "Orca에서 제거", "reconnectAndDelete": "재연결 후 삭제" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "{{value0}}개 세션을 종료했습니다.", "63520148e2": "{{value0}}개 세션이 종료를 거부했습니다.", "cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다.", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "터미널 탭 누락: {{value0}}/{{value1}}. 닫기 시도 실패: {{value2}}. 정확한 PTY 종료 요청 수락: {{value3}}; 실패: {{value4}}.", "2e57c1a940": "관리 요청이 실패하여 데몬 종료 결과가 확인되지 않았습니다.", "993af6052c": "데몬 관리가 종료를 보고함: {{value0}}/{{value1}}; 정확한 정리 전에 아직 존재: {{value2}}.", "1f0d8ac762": "터미널 정리가 오류와 함께 완료되었습니다.", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "원래 창으로 이동", "jumpToWorktree": "작업 트리로 이동", "recoverableBadge": "저장 안 됨", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "서브에이전트 1개", + "subagentCountPlural": "서브에이전트 {{value0}}개" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다." }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "서브에이전트 ({{value0}})", + "messageCount": "{{value0}}개 메시지", + "viewLog": "로그 보기" } } }, @@ -10483,9 +10483,9 @@ "fb5f28330e": "사이드바에 표시", "c669abcf8f": "사이드바에서 숨기기", "e1c7b4a92d": "Configure in Settings > Mobile.", - "a4f8c2d91e": "Remove Orca Mobile from left sidebar", - "b7e3d1c84a": "Show Orca Mobile in left sidebar", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "a4f8c2d91e": "왼쪽 사이드바에서 Orca Mobile 제거", + "b7e3d1c84a": "왼쪽 사이드바에 Orca Mobile 표시", + "f3d8e5b71a": "단축키를 왼쪽 사이드바에 다시 추가합니다." }, "PhoneCarousel": { "96d651cb87": "Terminal 세션", @@ -12685,7 +12685,7 @@ "profiles": { "switcher": { "cloud": { - "unavailable": "클라우드 로그인을 사용할 수 없습니다" + "unavailable": "클라우드 로그인 사용 불가" }, "reconnect": "프로필 다시 연결", "connect": "프로필 연결", @@ -12693,7 +12693,7 @@ "create": { "profile": { "for": { - "org": "조직용 프로필 만들기" + "org": "조직용 프로필 생성" } } }, @@ -12729,51 +12729,51 @@ "confirm": { "move": { "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{activeProfileName}}에서 제거하고 파일은 그대로 유지한 채 {{targetName}}(으)로 다시 시작합니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", "action": "프로젝트 이동" }, "copy": { "title": "프로젝트를 복사하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필은 같은 파일을 가리키며 Orca 메타데이터는 별도로 유지됩니다.", + "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필 모두 동일한 파일을 별도의 Orca 메타데이터와 함께 가리킵니다.", "action": "프로젝트 복사" }, "live": { - "work": "이 프로젝트에서 진행 중인 작업" + "work": "이 프로젝트의 활성 작업" }, "cancel": "취소", - "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", - "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", - "agentSingular": "{{count}}개의 활성 에이전트", - "agentPlural": "{{count}}개의 활성 에이전트", - "browserSingular": "{{count}}개의 브라우저 워크스페이스", - "browserPlural": "{{count}}개의 브라우저 워크스페이스" + "terminalSingular": "활성 터미널 탭 {{count}}개", + "terminalPlural": "활성 터미널 탭 {{count}}개", + "agentSingular": "활성 에이전트 {{count}}개", + "agentPlural": "활성 에이전트 {{count}}개", + "browserSingular": "브라우저 작업 공간 {{count}}개", + "browserPlural": "브라우저 작업 공간 {{count}}개" } } }, "signout": { "confirm": { "title": "로그아웃하시겠습니까?", - "description": "{{profileName}}에서 로그아웃하고 해당 프로젝트, 워크트리 및 로컬 메타데이터는 이 기기에 유지합니다.", + "description": "{{profileName}}에서 로그아웃하고 프로젝트, 워크트리 및 로컬 메타데이터를 이 장치에 유지합니다.", "cancel": "취소", "action": "로그아웃" } }, "switch": { "confirm": { - "target": "선택한 프로필", + "target": "선택된 프로필", "title": "프로필을 전환하시겠습니까?", - "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 워크스페이스가 다시 로드됩니다.", + "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 작업 공간이 다시 로드됩니다.", "live": { - "work": "이 프로필에서 진행 중인 작업" + "work": "이 프로필의 활성 작업" }, "cancel": "취소", "switch": "프로필 전환", - "terminalSingular": "{{count}}개의 실행 중인 터미널 탭", - "terminalPlural": "{{count}}개의 실행 중인 터미널 탭", - "agentSingular": "{{count}}개의 활성 에이전트", - "agentPlural": "{{count}}개의 활성 에이전트", - "browserSingular": "{{count}}개의 브라우저 워크스페이스", - "browserPlural": "{{count}}개의 브라우저 워크스페이스" + "terminalSingular": "활성 터미널 탭 {{count}}개", + "terminalPlural": "활성 터미널 탭 {{count}}개", + "agentSingular": "활성 에이전트 {{count}}개", + "agentPlural": "활성 에이전트 {{count}}개", + "browserSingular": "브라우저 작업 공간 {{count}}개", + "browserPlural": "브라우저 작업 공간 {{count}}개" } }, "org": { @@ -12790,18 +12790,18 @@ } }, "remove": { - "confirm": "삭제", + "confirm": "제거", "cancel": "취소", - "label": "구성원 삭제" + "label": "팀원 제거" }, "title": "조직 구성원", "load": { - "error": "조직 구성원을 불러오지 못했습니다." + "error": "조직 구성원을 로드할 수 없습니다." }, "retry": "다시 시도", "pending": { "title": "대기 중인 초대", - "revoke": "철회" + "revoke": "취소" }, "invite": { "title": "초대", @@ -12810,24 +12810,24 @@ "submit": "보내기" }, "error": { - "forbidden": "현재 역할로는 이 작업을 수행할 수 없습니다.", + "forbidden": "이 작업을 수행할 권한이 없습니다.", "conflict": "이미 구성원이거나 초대되었습니다.", "remove": { - "self": "자신은 삭제할 수 없습니다." + "self": "자신을 제거할 수 없습니다." }, "own": { - "role": "자신의 역할은 변경할 수 없습니다." + "role": "자신의 역할을 변경할 수 없습니다." }, "not": { - "found": "해당 구성원이 더 이상 이 조직에 없습니다." + "found": "해당 팀원은 이 조직에 없습니다." }, "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", - "unconfigured": "이 빌드에서는 조직 관리를 사용할 수 없습니다.", - "failed": "문제가 발생했습니다. 다시 시도하세요." + "unconfigured": "이 빌드에서 조직 관리를 사용할 수 없습니다.", + "failed": "문제가 발생했습니다. 다시 시도해 주세요." }, "subtitle": { - "named": "{{orgName}}에서 Orca로 협업할 수 있는 사용자입니다.", - "default": "조직에서 Orca로 협업할 수 있는 사용자입니다." + "named": "{{orgName}}에서 Orca에 협업할 수 있는 사람들.", + "default": "조직에서 Orca에 협업할 수 있는 사람들." } } } @@ -12965,4 +12965,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7110ab9ad65..688b5341f3d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3803,7 +3803,7 @@ "8c097ef04e": "来自 Orca。它仍保留在您的磁盘上。", "e62415c3d0": "这仅删除", "b79b39d865": "删除项目", - "fromOrcaSsh": "from Orca. Its files stay on {{value0}} — re-add that SSH host to recover it." + "fromOrcaSsh": "从 Orca 中移除。其文件保留在 {{value0}} 上 — 重新添加该 SSH 主机即可恢复。" }, "ScrollToCurrentWorkspaceToolbarButton": { "23989bb663": "显示活动工作区" @@ -4085,7 +4085,7 @@ "3e65e11cc6": "工作区元数据", "c7fa72ead0": "编辑笔记", "93cbea12c2": "笔记", - "eace1d2cf6": "Neutral", + "eace1d2cf6": "中性", "dbe2d18972": "更多 {{value0}} 操作", "ae76907ca6": "取消链接 {{value0}}", "ad25c3ff05": "在 {{value0}} 上查看", @@ -4191,7 +4191,7 @@ "045a8aed48": "孩子们", "2ca6e29a3c": "仓库", "bb85cd86ba": "为 {{value0}} 创建工作区", - "ebadb7eadb": "{{value0}} {{value1}} child {{value2}}", + "ebadb7eadb": "{{value0}} {{value1}} 子项 {{value2}}", "20bebf9c7f": "显示 {{value0}} 个子工作区", "c1f4a31623": "显示 {{value0}} 个子工作区", "e97297cb75": "隐藏 {{value0}} 个子工作区", @@ -4290,8 +4290,8 @@ "ead7b8ee15": "它已更改文件。无论如何,请使用强制删除将其删除。", "905fc8efac": "Git 已经删除了这个工作区。使用强制删除将其从 Orca 中清除。", "0899ebdb28": "Git 已经忘记了这个工作区,但它的目录仍然在磁盘上。使用强制删除来删除孤立目录。", - "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", - "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion." + "locked": "此工作区被 Git 锁定。请在其仓库中运行 git worktree unlock ,然后重试删除。", + "lockedReason": "此工作区被 Git 锁定。Git 报告:{{value0}}。请在其仓库中运行 git worktree unlock ,然后重试删除。" } } }, @@ -4523,13 +4523,13 @@ "6f7a8b9c0d": "取消", "7a8b9c0d1e": "打开设置", "8b9c0d1e2f": "删除主机", - "workspacesFailed": "Could not remove {{count}} of this host’s workspaces. The host was kept so you can retry.", + "workspacesFailed": "无法移除此主机的 {{count}} 个工作区。主机已保留,您可以重试。", "oneWorkspace": "1 个工作区", - "manyWorkspaces": "{{count}} workspaces", - "hostHasWorkspacesDefault": "Removes {{value0}} and its credentials from this computer. Its {{value1}} stay in Orca — remote files are not touched.", - "alsoDeleteRemote": "Also delete these {{value0}} on {{value1}}", - "alsoForgetLocal": "Also remove these {{value0}} from Orca", - "advanced": "Advanced", + "manyWorkspaces": "{{count}} 个工作区", + "hostHasWorkspacesDefault": "从此计算机移除 {{value0}} 及其凭据。其 {{value1}} 保留在 Orca 中 — 远程文件不会被删除。", + "alsoDeleteRemote": "同时在 {{value1}} 上删除这些 {{value0}}", + "alsoForgetLocal": "同时从 Orca 中移除这些 {{value0}}", + "advanced": "高级", "alsoDeleteRemoteHint": "永久删除远程 Git worktree 及其分支。此操作不可撤销。", "alsoForgetLocalHint": "仅从 Orca 中清除。远程文件、worktree 和分支不受影响。" }, @@ -4647,11 +4647,11 @@ }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "重新连接失败", - "forgetBody": "Removes this workspace from Orca only. Files, the Git worktree, and branches on {{host}} are left untouched.", - "title": "Delete “{{name}}”?", + "forgetBody": "仅从 Orca 中移除此工作区。文件、Git 工作树和 {{host}} 上的分支保持不变。", + "title": "从 Orca 中移除“{{name}}”?", "disconnectedBody": "此工作区的 SSH 主机未连接。重新连接以同时在远程删除,或仅从 Orca 中移除。", - "ghostBody": "{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.", - "cancel": "Cancel", + "ghostBody": "{{host}} 不再是已保存的 SSH 主机,因此此工作区不再连接到活跃主机。只能从 Orca 中移除 — 远程文件和分支保持不变。", + "cancel": "取消", "forget": "从 Orca 中移除", "reconnectAndDelete": "重新连接并删除" } @@ -4675,7 +4675,7 @@ "a2f040ac1c": "已终止 {{value0}} 个会话。", "63520148e2": "{{value0}} 个会话拒绝退出。", "cc0a26cb14": "{{value0}} 个会话拒绝退出。", - "71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.", + "71a8d342b0": "终端标签页缺失:{{value0}}/{{value1}}。关闭尝试失败:{{value2}}。精确 PTY 关闭请求已接受:{{value3}};失败:{{value4}}。", "2e57c1a940": "守护进程关闭结果未经验证,因为其管理请求失败。", "993af6052c": "守护进程管理报告已退出:{{value0}}/{{value1}};在精确清理前仍存在:{{value2}}。", "1f0d8ac762": "终端清理完成,但有错误。", @@ -9532,7 +9532,7 @@ "bar": { "buttons": { "1fd284e931": "更多侧边栏选项卡", - "f1132ea95d": "Neutral" + "f1132ea95d": "中性" } } }, @@ -9997,7 +9997,7 @@ "recoverableEmptyOpenLogHint": "打开日志以恢复它们。", "emptyConversationDetail": "此会话没有已保存的对话,无法恢复。", "queuedMessages": "{{value0}} 条排队消息", - "subagentTranscripts": "{{value0}} 个子代理记录" + "subagentTranscripts": "{{value0}} 个子智能体记录" }, "AiVaultSessionRow": { "resumeAgentSession": "恢复 {{value0}} 会话", @@ -10025,8 +10025,8 @@ "jumpToOriginalPane": "跳转到原始窗格", "jumpToWorktree": "跳转到工作树", "recoverableBadge": "未保存", - "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountSingular": "1 个子智能体", + "subagentCountPlural": "{{value0}} 个子智能体" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", @@ -10083,9 +10083,9 @@ "noActiveWorktreeTarget": "没有可用的活动工作树。" }, "AiVaultSessionSubagents": { - "subagentsCount": "Subagents ({{value0}})", - "messageCount": "{{value0}} msgs", - "viewLog": "View Log" + "subagentsCount": "子智能体({{value0}})", + "messageCount": "{{value0}} 条消息", + "viewLog": "查看日志" } } }, @@ -10485,7 +10485,7 @@ "e1c7b4a92d": "在设置 > 移动端中配置。", "a4f8c2d91e": "从左侧边栏隐藏 Orca 手机端", "b7e3d1c84a": "在左侧边栏显示 Orca 手机端", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "f3d8e5b71a": "将快捷方式添加回左侧边栏。" }, "PhoneCarousel": { "96d651cb87": "终端会话", From b677b2a209e66ee3693c7c9e11a67acf7616b6c8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 11 Jul 2026 02:16:13 -0400 Subject: [PATCH 91/95] fix(github): recover oversized issue creation (#8217) * fix(github): recover oversized issue creation (#7704) * fix(github): preserve partial issue creation --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/github/issues.test.ts | 157 ++++++++++++++++++ src/main/github/issues.ts | 91 +++++++--- src/preload/api-types.ts | 3 +- src/preload/index.ts | 4 +- src/renderer/src/components/TaskPage.tsx | 61 ++++--- .../task-page-github-issue-creation.test.ts | 33 ++++ src/shared/types.ts | 4 + 7 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-issue-creation.test.ts diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index f68726b395a..0f65fc1572c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -239,6 +239,163 @@ describe('issue source operations', () => { ) }) + it('recovers issue 7704 oversized inline-image creation', async () => { + const imagePrefix = 'data:image/png;base64,' + const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length) + expect(body).toContain('data:image') + expect(body).toHaveLength(133596) + + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 422: body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 926, + html_url: 'https://github.com/stablyai/orca/issues/926' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue('/repo-root', 'Image issue', body, undefined, undefined, { + labels: ['bug'], + assignees: ['octo'] + }) + ).resolves.toEqual({ + ok: true, + number: 926, + url: 'https://github.com/stablyai/orca/issues/926' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + `body=${body}`, + 'title=Image issue', + 'labels[]=bug', + 'assignees[]=octo' + ]), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']), + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`], + { cwd: '/repo-root' } + ) + }) + + it('recognizes the oversized-body response from structured gh stderr', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('Command failed: gh'), { + stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 929, + html_url: 'https://github.com/stablyai/orca/issues/929' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 929, + url: 'https://github.com/stablyai/orca/issues/929' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('does not retry unrelated create failures', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid')) + + await expect(createIssue('/repo-root', 'Invalid issue', 'Body')).resolves.toEqual({ + ok: false, + error: 'HTTP 422: assignees is invalid' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('stops when placeholder create fails during oversized-body recovery', async () => { + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockRejectedValueOnce(new Error('HTTP 500: create failed')) + + await expect(createIssue('/repo-root', 'Placeholder failure', body)).resolves.toEqual({ + ok: false, + error: 'HTTP 500: create failed' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('preserves fields during oversized-body recovery', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + const body = `data:image/png;base64,${'x'.repeat(100)}` + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) }) + .mockResolvedValueOnce({ stdout: '' }) + + await expect( + createIssue( + '/repo-root', + 'Fields issue', + body, + undefined, + null, + { + labels: ['bug'], + assignees: ['octo'] + }, + localGitOptions + ) + ).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' }) + const firstCreateArgs = [...ghExecFileAsyncMock.mock.calls[0][0]] + const fallbackCreateArgs = [...ghExecFileAsyncMock.mock.calls[1][0]] + firstCreateArgs[firstCreateArgs.indexOf(`body=${body}`)] = 'body=' + expect(fallbackCreateArgs).toEqual(firstCreateArgs) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + null, + localGitOptions + ) + }) + + it('reports partial success when oversized-body patch fails', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)')) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 928, + html_url: 'https://github.com/stablyai/orca/issues/928' + }) + }) + .mockRejectedValueOnce(new Error('HTTP 500: update failed')) + + await expect(createIssue('/repo-root', 'Partial issue', 'data:image')).resolves.toEqual({ + ok: true, + number: 928, + url: 'https://github.com/stablyai/orca/issues/928', + bodySaveWarning: + 'Issue https://github.com/stablyai/orca/issues/928 was created, but saving its body failed: HTTP 500: update failed' + }) + }) + it('updates issue body through the REST issue endpoint', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 8f80fc6451f..b0f3fa6cb89 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -6,6 +6,7 @@ import type { ClassifiedError, GitHubAssignableUser, GitHubCreateIssueFields, + GitHubCreateIssueResult, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, @@ -15,7 +16,7 @@ import type { import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' // prettier-ignore -import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an @@ -31,6 +32,11 @@ export type IssueListResult = { error?: ClassifiedError } +function githubIssueErrorMessage(error: unknown): string { + const { stderr, stdout } = extractExecError(error) + return stderr.trim() || stdout.trim() +} + /** * Get a single issue by number. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. @@ -162,7 +168,7 @@ export async function createIssue( connectionId?: string | null, fields?: GitHubCreateIssueFields, localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { +): Promise { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } @@ -180,25 +186,71 @@ export async function createIssue( } await acquire() try { - const args = [ - 'api', - '-X', - 'POST', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, - '--raw-field', - `title=${trimmedTitle}`, - '--raw-field', - `body=${body}` - ] - for (const label of fields?.labels ?? []) { - args.push('--raw-field', `labels[]=${label}`) + const createArgs = (issueBody: string) => { + const args = [ + 'api', + '-X', + 'POST', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, + '--raw-field', + `title=${trimmedTitle}`, + '--raw-field', + `body=${issueBody}` + ] + for (const label of fields?.labels ?? []) { + args.push('--raw-field', `labels[]=${label}`) + } + for (const assignee of fields?.assignees ?? []) { + args.push('--raw-field', `assignees[]=${assignee}`) + } + return args } - for (const assignee of fields?.assignees ?? []) { - args.push('--raw-field', `assignees[]=${assignee}`) + + const parseIssue = (stdout: string) => + JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } + + let data: { number?: number; html_url?: string; url?: string } + try { + const { stdout } = await ghExecFileAsync(createArgs(body), ghOptions) + data = parseIssue(stdout) + } catch (err) { + const message = githubIssueErrorMessage(err) + if (!/body is too long \(maximum is \d+ characters\)/i.test(message)) { + return { ok: false, error: message } + } + + // Why: GitHub rejects oversized bodies on create but accepts the same body + // on update, so establish the issue before attaching its body. + const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions) + data = parseIssue(stdout) + if (typeof data.number !== 'number') { + return { ok: false, error: 'Unexpected response from GitHub' } + } + + try { + await ghExecFileAsync( + [ + 'api', + '-X', + 'PATCH', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${data.number}`, + '--raw-field', + `body=${body}` + ], + ghOptions + ) + } catch (patchErr) { + const patchMessage = githubIssueErrorMessage(patchErr) + const identity = data.html_url ?? data.url ?? `#${data.number}` + return { + ok: true, + number: data.number, + url: String(data.html_url ?? data.url ?? ''), + bodySaveWarning: `Issue ${identity} was created, but saving its body failed: ${patchMessage}` + } + } } - const { stdout } = await ghExecFileAsync(args, ghOptions) - const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } } @@ -208,8 +260,7 @@ export async function createIssue( url: String(data.html_url ?? data.url ?? '') } } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } + return { ok: false, error: githubIssueErrorMessage(err) } } finally { release() } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7731355972c..7c9f989be5a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -89,6 +89,7 @@ import type { GitStatusResult, GitUpstreamStatus, GitHubAssignableUser, + GitHubCreateIssueResult, GitHubPRFile, GitHubPRFileContents, GitHubPrStartPoint, @@ -1488,7 +1489,7 @@ export type PreloadApi = { body: string labels?: string[] assignees?: string[] - }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + }) => Promise countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }) => Promise listWorkItems: (args: { repoPath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 4ad832fa89a..059e2aa4e90 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import type { GitHubPRRefreshReason, GitHubAssignableUser, GitHubCommentResult, + GitHubCreateIssueResult, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -1262,8 +1263,7 @@ const api = { body: string labels?: string[] assignees?: string[] - }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:createIssue', args), + }): Promise => ipcRenderer.invoke('gh:createIssue', args), countWorkItems: (args: { repoPath: string diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 8d37800b8d9..c42be0d7bac 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -6865,7 +6865,9 @@ export default function TaskPage(): React.JSX.Element { labels: newIssueLabels, assignees: newIssueAssignees.map((assignee) => assignee.login) }, - { timeoutMs: 30_000 } + // Why: oversized-body recovery can require two 30-second writes + // after GitHub rejects the initial create request. + { timeoutMs: 65_000 } ) : await window.api.gh.createIssue({ repoPath: newIssueTargetRepo.path, @@ -6883,28 +6885,42 @@ export default function TaskPage(): React.JSX.Element { ) return } - toast.success( - translate('auto.components.TaskPage.3f9604efc7', 'Opened issue #{{value0}}', { - value0: result.number - }), - { - action: result.url - ? { - label: translate('auto.components.TaskPage.9c57663908', 'View'), - onClick: () => window.open(result.url, '_blank') - } - : undefined - } + const createdIssueToast = translate( + 'auto.components.TaskPage.3f9604efc7', + 'Opened issue #{{value0}}', + { value0: result.number } ) + const createdIssueToastOptions = { + action: result.url + ? { + label: translate('auto.components.TaskPage.9c57663908', 'View'), + onClick: () => window.open(result.url, '_blank') + } + : undefined + } + if (result.bodySaveWarning) { + toast.warning(createdIssueToast, { + ...createdIssueToastOptions, + description: result.bodySaveWarning + }) + } else { + toast.success(createdIssueToast, createdIssueToastOptions) + } setNewIssueOpen(false) - setNewIssueTitle('') - setNewIssueBody('') - setNewIssueLabels([]) - setNewIssueAssignees([]) - // Why: a successful submit is the only path that discards the recovery - // draft. Closing `newIssueOpen` in the same commit keeps the write-through - // effect (gated on it) from re-persisting the emptied fields. - clearNewIssueDraft() + if (result.bodySaveWarning) { + // Why: retain the unsaved body for recovery, but clear the title so + // reopening the composer cannot repeat the create with one click. + setNewIssueTitle('') + setNewIssueDraft({ title: '' }) + } else { + setNewIssueTitle('') + setNewIssueBody('') + setNewIssueLabels([]) + setNewIssueAssignees([]) + // Why: only a complete success discards the recovery draft; a partial + // body save keeps the original text available without another create. + clearNewIssueDraft() + } // Why: bump the nonce so the list refetches and shows the new issue. setTaskRefreshNonce((current) => current + 1) @@ -6973,7 +6989,8 @@ export default function TaskPage(): React.JSX.Element { newIssueTitle, openGitHubDetailPage, setDialogWorkItem, - clearNewIssueDraft + clearNewIssueDraft, + setNewIssueDraft ]) const handleCreateNewLinearProject = useCallback(async (): Promise => { diff --git a/src/renderer/src/components/task-page-github-issue-creation.test.ts b/src/renderer/src/components/task-page-github-issue-creation.test.ts new file mode 100644 index 00000000000..58335c3ce7f --- /dev/null +++ b/src/renderer/src/components/task-page-github-issue-creation.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const taskPageSource = readFileSync(new URL('./TaskPage.tsx', import.meta.url), 'utf8') + +function issueCreationSection(): string { + const start = taskPageSource.indexOf('const handleCreateNewIssue') + const end = taskPageSource.indexOf('const handleCreateNewLinearProject', start) + return taskPageSource.slice(start, end) +} + +describe('TaskPage GitHub issue creation', () => { + it('covers the complete remote oversized-body recovery timeout envelope', () => { + const section = issueCreationSection() + + expect(section).toContain("'github.createIssue'") + expect(section).toContain('{ timeoutMs: 65_000 }') + }) + + it('treats a body-save warning as created while preserving the recovery draft', () => { + const section = issueCreationSection() + const warningBranch = section.slice( + section.indexOf('if (result.bodySaveWarning)'), + section.indexOf('// Why: bump the nonce') + ) + + expect(warningBranch).toContain('toast.warning') + expect(warningBranch).toContain('description: result.bodySaveWarning') + expect(warningBranch).toContain("setNewIssueDraft({ title: '' })") + expect(warningBranch).toContain('} else {') + expect(warningBranch).toContain('clearNewIssueDraft()') + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 6079fa4fe9b..24308e9327f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1743,6 +1743,10 @@ export type GitHubCreateIssueFields = { assignees?: string[] } +export type GitHubCreateIssueResult = + | { ok: true; number: number; url: string; bodySaveWarning?: string } + | { ok: false; error: string } + export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' export type GitHubIssueUpdate = { From 931e402e93f864581707db54891afdac3613b3f9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:25:46 -0700 Subject: [PATCH 92/95] Fix tui-idle detection for cursor-agent lanes with dismissed trust dialog (#8220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix tui-idle detection for Cursor Agent lanes with dismissed trust dialo Cursor Agent's persistent TUI never emits an idle OSC title, so a dismissed trust dialog stayed in scrollback and kept the wait blocked even once Cursor reached its idle "→" prompt. Detect Cursor's active prompt (busy or idle) to clear a stale trust hit, and detect its idle state specifically (prompt present, no braille spinner) to satisfy tui-idle, so busy lanes keep polling instead of returning a stale blocked result. * Add test coverage for Cursor Agent trust-dialog banner detection Verify lastIndexOf picks the later ready banner over an earlier "Cursor Agent" mention in the trust dialog body, for both the idle-resolution and busy-detection cases. * chore(i18n): sync localization catalog for aiVaultSessionLogOpen keys Pre-existing gap from #8205 (ai-vault view log) that fails `pnpm lint`'s localization-catalog check on origin/main; unrelated to the tui-idle fix but committed here to unblock CI lint on this branch. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 92 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 52 +++++++++++++-- src/renderer/src/i18n/locales/en.json | 5 ++ src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 5 ++ 7 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2b515e230bb..7a9e927cbfa 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -874,6 +874,32 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High ].join('\n') } +// Why: verbatim shape of a real cursor-agent 2026.07 idle screen. The fresh +// input prompt is "→ Plan, search, build anything" (not "→ Add a follow-up", +// which only appears after the first turn), so the matcher keys on the "→" +// glyph, not a specific placeholder. +function cursorReadyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + 'Tip: Use /plan to plan execution and reach the right outcome faster.', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + +function cursorBusyScreen(): string { + return [ + 'Cursor Agent', + 'v2026.07.09-a3815c0', + '⠰⠳ Thinking 28.61k tokens', + '→ Plan, search, build anything', + 'Composer 2.5 Fast Run Everything', + '~/Documents/projects/AutoGenie · main' + ].join('\n') +} + // Why: these runtime feature tests only need message-queue semantics; using // SQLite here makes them fail on unrelated native runtime ABI drift. class InMemoryOrchestrationMessages { @@ -10525,6 +10551,72 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle for an idle Cursor lane past its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + // Cursor's dismissed trust dialog stays in the scrollback tail; the idle + // ready prompt after it must both clear the stale trust hit and satisfy idle. + runtime.onPtyData( + 'pty-bg', + [ + // Trust dialog body can mention "Cursor Agent" before the ready banner; + // lastIndexOf must pick the later banner, not this earlier hit. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorReadyScreen() + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running' + }) + }) + + it('does not block a mid-run Cursor lane on its dismissed trust dialog (#8210)', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'cursor-agent' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + // Same earlier "Cursor Agent" hit as the idle case — banner must win. + 'Cursor Agent\n', + '⚠ Workspace Trust Required\n', + 'Do you trust the contents of this directory?\n', + ' ▶ [a] Trust this workspace\n', + ' [q] Quit\n', + cursorBusyScreen() + ].join(''), + Date.now() + ) + + // Busy Cursor is neither blocked nor idle: the wait must keep polling, then + // time out honestly rather than return a stale trust block. + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + ).rejects.toThrow('timeout') + }) + it('returns a blocked wait result for Codex cwd selection prompts', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0ed4215aa8f..75beac99452 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25558,20 +25558,64 @@ function findActionableTerminalWaitBlockedSignal( if (blockedSignal === null) { return null } - const readyIndex = findKnownReadyPromptIndex(normalized) + const dismissedModalIndex = findDismissedStartupModalIndex(normalized) // Why: retained terminal tails can include stale startup modals. If a known - // ready prompt appears after that modal, the latest signal is ready. - return readyIndex !== null && readyIndex > blockedSignal.index ? null : blockedSignal + // agent's live prompt appears after that modal, the modal was dismissed and + // the signal is no longer actionable — even if the agent is still mid-run + // (Cursor never reports idle via OSC title, so its busy prompt clears too). + return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index + ? null + : blockedSignal +} + +// Why: a recognized agent's live prompt (idle OR busy) proves its startup modal +// was dismissed. Broader than the idle-only ready set so a mid-run Cursor lane +// stops reporting a stale trust hit for the rest of the session. +function findDismissedStartupModalIndex(normalized: string): number | null { + const indexes = [ + findCodexReadyPromptIndex(normalized), + findAntigravityReadyPromptIndex(normalized), + findCursorActivePromptIndex(normalized) + ].filter((index): index is number => index !== null) + return indexes.length > 0 ? Math.max(...indexes) : null } function findKnownReadyPromptIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized) + findAntigravityReadyPromptIndex(normalized), + findCursorReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } +// Why: cursor-agent keeps a persistent TUI — a printed "Cursor Agent" banner and +// a "→" input-prompt line appear once its trust dialog is dismissed, in both +// busy and idle states. The banner is matched by its last occurrence so the +// trust dialog's own "Cursor Agent" body text (which precedes the banner) does +// not win. The "→" glyph is cursor-agent's input prompt marker ("→ Plan, +// search, build anything" fresh, "→ Add a follow-up" after the first turn). +function findCursorActivePromptIndex(normalized: string): number | null { + const headerIndex = normalized.lastIndexOf('cursor agent') + if (headerIndex === -1) { + return null + } + return normalized.includes('→', headerIndex) ? headerIndex : null +} + +// Why: cursor-agent never emits an idle OSC title (its bare title is dropped), +// so tui-idle can only resolve from the tail. Busy frames draw a braille +// spinner in the on-screen status line; its absence past the banner is idle. +const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ + +function findCursorReadyPromptIndex(normalized: string): number | null { + const activeIndex = findCursorActivePromptIndex(normalized) + if (activeIndex === null) { + return null + } + return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex +} + function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index eb1b76a447a..0a60a2d35c9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10086,6 +10086,11 @@ "subagentsCount": "Subagents ({{value0}})", "messageCount": "{{value0}} msgs", "viewLog": "View Log" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ffffbfb77bd..b8c369477dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10086,6 +10086,11 @@ "subagentsCount": "Subagentes ({{value0}})", "messageCount": "{{value0}} msgs", "viewLog": "Ver registro" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f752cfadf71..2796e629344 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10086,6 +10086,11 @@ "subagentsCount": "サブエージェント ({{value0}})", "messageCount": "{{value0}}件のメッセージ", "viewLog": "ログを表示" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9c32e81f71..9cd90833df5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10086,6 +10086,11 @@ "subagentsCount": "서브에이전트 ({{value0}})", "messageCount": "{{value0}}개 메시지", "viewLog": "로그 보기" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, @@ -12965,4 +12970,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 688b5341f3d..ee6099601e6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10086,6 +10086,11 @@ "subagentsCount": "子智能体({{value0}})", "messageCount": "{{value0}} 条消息", "viewLog": "查看日志" + }, + "aiVaultSessionLogOpen": { + "workspaceGone": "Couldn't open log — workspace is no longer available.", + "notAuthorized": "Couldn't open log — path not authorized.", + "alreadyEditable": "Log is already open for editing." } } }, From 2e4849527300547f1febe62dfef7ef70101bad92 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:31:56 -0700 Subject: [PATCH 93/95] Prevent mobile screen locking during voice dictation. (#7746) * Prevent mobile screen locking during voice dictation Integrate expo-keep-awake to prevent the mobile device from locking or sleeping while a voice dictation session is active. - Modularize useMobileDictation logic into separate helper files for keep-awake, audio chunking, session state, and desktop startup. - Acquire keep-awake lock only after successfully establishing a desktop session to avoid locking on stale start attempts. - Release the keep-awake lock on all completion, cancellation, error, and unmount paths. - Add source invariant unit tests to verify keep-awake ownership and strict cleanup ordering. * serialize keep-awake operations and avoid stale dictation start races - Implement a global execution queue and tag tracking for keep-awake operations to prevent concurrent races and stale deactivations. - Track failed native deactivations and retry them when a replacement hook owner mounts or starts a new dictation session. - Ensure stale or canceled desktop dictation starts do not reset the UI state or propagate outdated start/keep-awake failures. - Reuse the audio chunk queue wiring in useMobileDictation to avoid allocating new closure objects on the high-frequency microphone path. - Add comprehensive unit tests for the keep-awake and desktop start hooks. * Commit native recording during dictation session startup Commit native recording in the same continuation as the final session stale check. This prevents a queued cancellation from resurrecting the microphone recording after cleanup has already run. If microphone initialization fails or throws, acquired resources (like keep-awake locks and the remote desktop session) are properly rolled back. * Make keep-awake acquisition best-effort with a bounded startup timeout - Recording start no longer blocks (or fails) on keep-awake acquisition: a hung or failing native call is capped at a short budget and logged instead of delaying or aborting dictation. - Add native-call timeouts, orphan-tag tracking, and reacquire/drain logic in mobile-dictation-keep-awake.ts so Activity recreation on Android and stale tags no longer wedge the keep-awake queue. - Add useMobileDictationForegroundKeepAwake to refresh the wake tag on Android foreground and retry failed refreshes/deactivations. - Hold the wake tag through chunk drain and the finish RPC so a screen lock can't suspend the app before the transcript arrives, and keep cleanup running even if native recording shutdown throws. - Loosen expo-keep-awake to a caret range to unblock the patch pulling in these native fixes. * Fix cancellation races in mobile dictation keep-awake handling - Run wake-lock release and dictation cancel concurrently on stale starts so a hung acquisition no longer delays the native cancel - Guard foreground reacquire retries with a run token so a stale retry chain can't deactivate a wake lock reacquired by a newer AppState transition * Update source invariant test for concurrent stale-start cleanup Co-authored-by: Orca --------- Co-authored-by: Orca --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 3 + .../src/hooks/mobile-dictation-audio-chunk.ts | 52 +++ .../mobile-dictation-desktop-start.test.ts | 231 +++++++++ .../hooks/mobile-dictation-desktop-start.ts | 142 ++++++ .../mobile-dictation-foreground-keep-awake.ts | 70 +++ .../hooks/mobile-dictation-keep-awake.test.ts | 442 ++++++++++++++++++ .../src/hooks/mobile-dictation-keep-awake.ts | 236 ++++++++++ .../hooks/mobile-dictation-session-state.ts | 65 +++ .../hooks/use-mobile-dictation-source.test.ts | 253 +++++++++- mobile/src/hooks/use-mobile-dictation.ts | 229 +++++---- 11 files changed, 1594 insertions(+), 130 deletions(-) create mode 100644 mobile/src/hooks/mobile-dictation-audio-chunk.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-desktop-start.ts create mode 100644 mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.test.ts create mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.ts create mode 100644 mobile/src/hooks/mobile-dictation-session-state.ts diff --git a/mobile/package.json b/mobile/package.json index 9f91777a842..cbbc8c86bed 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -35,6 +35,7 @@ "expo-haptics": "^55.0.14", "expo-image-manipulator": "^55.0.17", "expo-image-picker": "^55.0.20", + "expo-keep-awake": "~55.0.8", "expo-linking": "^55.0.15", "expo-modules-core": "~55.0.25", "expo-network": "~55.0.14", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 496855fc266..aafe638813f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: expo-image-picker: specifier: ^55.0.20 version: 55.0.20(expo@55.0.27) + expo-keep-awake: + specifier: ~55.0.8 + version: 55.0.8(expo@55.0.27)(react@19.2.6) expo-linking: specifier: ^55.0.15 version: 55.0.15(expo@55.0.27)(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) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts new file mode 100644 index 00000000000..792292e6c16 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -0,0 +1,52 @@ +import { + MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, + MOBILE_DICTATION_PCM_SAMPLE_RATE +} from './mobile-dictation-pending-audio-budget' +import { bytesToBase64 } from './mobile-dictation-session-state' +import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio' +import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import type { RpcClient } from '../transport/rpc-client' + +type MobileDictationAudioChunkQueue = { + pendingChunks: Set> + pendingAudioBudget: MobileDictationPendingAudioBudget + shouldReleaseBudget: (dictationId: string) => boolean + failActiveDictation: (dictationId: string, err: unknown) => void +} + +export function enqueueMobileDictationAudioChunk( + client: RpcClient, + dictationId: string, + event: MicrophoneDataEvent, + queue: MobileDictationAudioChunkQueue +): void { + const raw = event.data + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) + const byteLength = bytes.byteLength + if (!queue.pendingAudioBudget.tryReserve(byteLength)) { + queue.failActiveDictation( + dictationId, + new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) + ) + return + } + const sendChunk = client + .sendRequest('speech.dictation.chunk', { + dictationId, + audioBase64: bytesToBase64(bytes), + sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE + }) + .then((response) => { + if (!response.ok) { + throw new Error(response.error.message) + } + }) + .catch((err) => queue.failActiveDictation(dictationId, err)) + .finally(() => { + if (queue.shouldReleaseBudget(dictationId)) { + queue.pendingAudioBudget.release(byteLength) + } + queue.pendingChunks.delete(sendChunk) + }) + queue.pendingChunks.add(sendChunk) +} diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts new file mode 100644 index 00000000000..bdd0b69d963 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +const OK_RESPONSE = { ok: true, result: {} } as const + +type StartHarnessOptions = { + sendRequest?: (method: string) => Promise + acquire?: () => Promise + commitRecordingStart?: () => boolean +} + +function createStartHarness(options: StartHarnessOptions = {}) { + let generation = 1 + let enabled = true + let activeId: string | null = 'dictation-a' + const setIdle = vi.fn() + const release = vi.fn().mockResolvedValue(undefined) + const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true)) + const rollbackRecordingStart = vi.fn() + const sendRequest = vi.fn( + options.sendRequest ?? (async () => OK_RESPONSE) + ) as unknown as RpcClient['sendRequest'] + const client = { sendRequest } as RpcClient + const keepAwakeOwner = { + acquire: vi.fn(options.acquire ?? (async () => undefined)), + release + } as unknown as MobileDictationKeepAwakeOwner + + return { + options: { + client, + dictationId: 'dictation-a', + generation: 1, + getCurrentGeneration: () => generation, + getEnabled: () => enabled, + getActiveId: () => activeId, + clearActiveId: (dictationId: string) => { + if (activeId === dictationId) { + activeId = null + } + }, + setIdle, + keepAwakeOwner, + commitRecordingStart, + rollbackRecordingStart + }, + setNewerStart: () => { + generation = 2 + activeId = 'dictation-b' + }, + setDisabled: () => { + enabled = false + }, + getActiveId: () => activeId, + setIdle, + release, + sendRequest, + commitRecordingStart, + rollbackRecordingStart + } +} + +describe('startMobileDictationDesktopSession', () => { + it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + acquire: async () => setNewerStart() + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('sends desktop cancellation without waiting for a hung keep-awake release', async () => { + vi.useFakeTimers() + try { + let goStale = () => undefined + const harness = createStartHarness({ + acquire: () => { + goStale() + return new Promise(() => undefined) + } + }) + goStale = harness.setNewerStart + // Release queues behind the still-running acquisition, so it never settles. + harness.release.mockReturnValue(new Promise(() => undefined)) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + // Cancellation is dispatched even though release is still pending, so the + // native session tears down without waiting out its own timeout. + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + + void startPromise + } finally { + vi.useRealTimers() + } + }) + + it('returns to idle when disable makes keep-awake acquisition stale', async () => { + let setDisabled = () => undefined + const harness = createStartHarness({ + acquire: async () => setDisabled() + }) + setDisabled = harness.setDisabled + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.getActiveId()).toBeNull() + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('does not hold recording start on a hung keep-awake acquisition', async () => { + vi.useFakeTimers() + try { + const harness = createStartHarness({ + acquire: () => new Promise(() => undefined) + }) + + const startPromise = startMobileDictationDesktopSession(harness.options) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + + await expect(startPromise).resolves.toBe(true) + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.release).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('continues dictation when keep-awake acquisition fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const harness = createStartHarness({ + acquire: async () => { + throw new Error('Unable to activate keep awake') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(consoleError).toHaveBeenCalledOnce() + consoleError.mockRestore() + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-a') + expect(harness.release).not.toHaveBeenCalled() + expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + }) + + it('does not surface a desktop-start failure after the start became stale', async () => { + let setNewerStart = () => undefined + const harness = createStartHarness({ + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setNewerStart() + throw new Error('Desktop start failed') + } + return OK_RESPONSE + } + }) + setNewerStart = harness.setNewerStart + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + + expect(harness.setIdle).not.toHaveBeenCalled() + expect(harness.getActiveId()).toBe('dictation-b') + expect(harness.commitRecordingStart).not.toHaveBeenCalled() + }) + + it('commits recording before returning a current start to the hook', async () => { + const harness = createStartHarness() + + await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) + + expect(harness.commitRecordingStart).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).not.toHaveBeenCalled() + }) + + it('cleans up the keep-awake tag and desktop session when native recording throws', async () => { + const harness = createStartHarness({ + commitRecordingStart: () => { + throw new Error('Audio focus request failed') + } + }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Audio focus request failed' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) + + it('rejects and cleans up when the native recorder does not start', async () => { + const harness = createStartHarness({ commitRecordingStart: () => false }) + + await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow( + 'Failed to start microphone recording' + ) + + expect(harness.release).toHaveBeenCalledWith('dictation-a') + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) + expect(harness.getActiveId()).toBeNull() + expect(harness.setIdle).toHaveBeenCalledOnce() + expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts new file mode 100644 index 00000000000..581b2b99d9f --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -0,0 +1,142 @@ +import { + MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS, + isCurrentMobileDictationStart +} from './mobile-dictation-session-state' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import type { RpcClient } from '../transport/rpc-client' + +type StartMobileDictationDesktopSessionOptions = { + client: RpcClient + dictationId: string + generation: number + getCurrentGeneration: () => number + getEnabled: () => boolean + getActiveId: () => string | null + clearActiveId: (dictationId: string) => void + setIdle: () => void + keepAwakeOwner: MobileDictationKeepAwakeOwner + commitRecordingStart: () => boolean + rollbackRecordingStart: () => void +} + +function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean { + return isCurrentMobileDictationStart( + options.getCurrentGeneration(), + options.generation, + options.getEnabled(), + options.getActiveId(), + options.dictationId + ) +} + +function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean { + return options.getCurrentGeneration() === options.generation && options.getEnabled() +} + +function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void { + if (options.getCurrentGeneration() === options.generation) { + options.setIdle() + } +} + +// Cancel a start that went stale mid-startup. The wake-lock release and remote +// cancel are independent, so run them concurrently: awaiting release first can +// queue behind a still-running acquisition and delay cancel for the remainder +// of the native timeout. +async function cancelStaleStart( + options: StartMobileDictationDesktopSessionOptions, + { releaseKeepAwake }: { releaseKeepAwake: boolean } +): Promise { + const { client, dictationId, keepAwakeOwner } = options + options.clearActiveId(dictationId) + setIdleIfGenerationCurrent(options) + const cleanups: Promise[] = [ + client.sendRequest('speech.dictation.cancel', { dictationId }) + ] + if (releaseKeepAwake) { + cleanups.push(keepAwakeOwner.release(dictationId)) + } + await Promise.allSettled(cleanups) +} + +export async function startMobileDictationDesktopSession( + options: StartMobileDictationDesktopSessionOptions +): Promise { + const { client, dictationId, keepAwakeOwner } = options + + try { + const response = await client.sendRequest('speech.dictation.start', { dictationId }) + if (!response.ok) { + throw new Error(response.error.message) + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + options.clearActiveId(dictationId) + await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) + // Awaited cleanup may overlap a newer start; stale work must not reset or + // report over the replacement session. + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: false }) + return false + } + + // Keep-awake is acquired only after the desktop session exists, so stale + // mobile starts can be canceled without holding a screen-lock tag. It is + // best-effort: Android throws with no current Activity, and a screen-lock + // nicety must not abort an otherwise viable dictation — nor delay recording + // past a small budget when native calls hang. A late acquisition finishes in + // the background; the serialized keep-awake queue orders any later release + // after it. + await new Promise((resolve) => { + const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) + keepAwakeOwner + .acquire(dictationId) + .catch((err: unknown) => console.error('Keep-awake activation failed', err)) + .finally(() => { + clearTimeout(budgetTimer) + resolve() + }) + }) + + if (!isCurrentStart(options)) { + await cancelStaleStart(options, { releaseKeepAwake: true }) + return false + } + + try { + // Commit in the same continuation as the final stale check; returning first + // would let a queued cancel resurrect microphone recording after cleanup. + if (!options.commitRecordingStart()) { + throw new Error('Failed to start microphone recording') + } + } catch (err) { + const wasCurrent = isCurrentStart(options) + // Native recording can partially start before throwing, so stop audio before + // releasing the wake tag and remote session. + try { + options.rollbackRecordingStart() + } catch { + // Continue releasing independently owned resources after native audio failure. + } + options.clearActiveId(dictationId) + await Promise.allSettled([ + keepAwakeOwner.release(dictationId), + client.sendRequest('speech.dictation.cancel', { dictationId }) + ]) + const shouldReport = wasCurrent && canReportStartFailure(options) + setIdleIfGenerationCurrent(options) + if (!shouldReport) { + return false + } + throw err + } + return true +} diff --git a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts new file mode 100644 index 00000000000..d5962d9d11e --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { AppState, Platform } from 'react-native' +import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' +import type { RefObject } from 'react' +import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' + +// A transient Activity gap can fail a foreground refresh; retry briefly while +// the same dictation is live instead of waiting for the next foreground. +const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000] + +let globalStaleTagDrainInstalled = false + +// Failed final deactivations must be retried even after every session screen +// unmounts, or a stale native tag keeps the screen awake until app restart. +// Installed once for the app's lifetime; the drain spares still-wanted tags +// and fast-paths to a no-op when nothing is pending. +function installGlobalStaleTagForegroundDrain(): void { + if (globalStaleTagDrainInstalled) { + return + } + globalStaleTagDrainInstalled = true + AppState.addEventListener('change', (state) => { + if (state === 'active') { + void drainMobileDictationKeepAwakeCleanup().catch(() => undefined) + } + }) +} + +export function useMobileDictationForegroundKeepAwake( + keepAwakeOwner: MobileDictationKeepAwakeOwner, + activeIdRef: RefObject +): void { + useEffect(() => { + installGlobalStaleTagForegroundDrain() + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity + // recreation silently drops it mid-dictation; refresh on return to + // active. iOS re-applies natively on foreground. + if (Platform.OS !== 'android') { + return + } + // A retry from an earlier foreground event can outlive a newer reacquire and + // deactivate the recovered tag; a run token invalidated on each AppState + // change and on unmount drops superseded retry chains. + let reacquireRun = 0 + const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => { + void keepAwakeOwner.reacquire(dictationId).catch(() => { + const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] + if (delay === undefined) { + return + } + setTimeout(() => { + if (reacquireRun === run && activeIdRef.current === dictationId) { + reacquireWithRetry(dictationId, attempt + 1, run) + } + }, delay) + }) + } + const sub = AppState.addEventListener('change', (state) => { + const run = ++reacquireRun + const dictationId = activeIdRef.current + if (state === 'active' && dictationId) { + reacquireWithRetry(dictationId, 0, run) + } + }) + return () => { + reacquireRun += 1 + sub.remove() + } + }, [keepAwakeOwner, activeIdRef]) +} diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts new file mode 100644 index 00000000000..362f3a0cf68 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const keepAwake = vi.hoisted(() => ({ + activate: vi.fn<(tag: string) => Promise>(), + deactivate: vi.fn<(tag: string) => Promise>() +})) + +vi.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: keepAwake.activate, + deactivateKeepAwake: keepAwake.deactivate +})) + +import { + MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, + MobileDictationKeepAwakeOwner, + drainMobileDictationKeepAwakeCleanup +} from './mobile-dictation-keep-awake' + +function deferred(): { + promise: Promise + resolve: () => void + reject: (error: Error) => void +} { + let resolvePromise: (() => void) | undefined + let rejectPromise: ((error: Error) => void) | undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + resolve: () => resolvePromise?.(), + reject: (error) => rejectPromise?.(error) + } +} + +describe('MobileDictationKeepAwakeOwner', () => { + beforeEach(() => { + keepAwake.activate.mockReset().mockResolvedValue(undefined) + keepAwake.deactivate.mockReset().mockResolvedValue(undefined) + }) + + it('retries a failed native deactivation after the hook owner is replaced', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + + await firstOwner.acquire('first') + const firstTag = keepAwake.activate.mock.calls[0]?.[0] + expect(firstTag).toContain(':first') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + const replacementOwner = new MobileDictationKeepAwakeOwner() + await replacementOwner.acquire('second') + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + expect(secondTag).toContain(':second') + expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await replacementOwner.release('second') + }) + + it('serializes cancel and restart without letting a stale release deactivate the restart', async () => { + const firstActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => firstActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + + const acquireFirst = owner.acquire('first') + const releaseFirst = owner.release('first') + const acquireSecond = owner.acquire('second') + firstActivation.resolve() + await Promise.all([acquireFirst, releaseFirst, acquireSecond]) + + const secondTag = keepAwake.activate.mock.calls[1]?.[0] + await owner.release('first') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('second') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag) + }) + + it('waits for an in-flight failed release before a replacement owner activates', async () => { + const deactivation = deferred() + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) + + const releaseFirst = firstOwner.release('first') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(keepAwake.deactivate).toHaveBeenCalledOnce() + + const replacementOwner = new MobileDictationKeepAwakeOwner() + const acquireReplacement = replacementOwner.acquire('replacement') + expect(keepAwake.activate).toHaveBeenCalledOnce() + + deactivation.reject(new Error('Activity unavailable')) + await expect(releaseFirst).rejects.toThrow('Activity unavailable') + await acquireReplacement + + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + await replacementOwner.release('replacement') + }) + + it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => { + const firstOwner = new MobileDictationKeepAwakeOwner() + await firstOwner.acquire('first') + + // Both the release deactivate and its trailing drain retry fail. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') + + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + const replacementOwner = new MobileDictationKeepAwakeOwner() + await expect(replacementOwner.acquire('second')).resolves.toBeUndefined() + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + // The still-pending first tag drains once a deactivation finally succeeds. + await replacementOwner.release('second') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength( + 4 + ) + }) + + it('times out a never-settling native call instead of wedging the queue', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const hungOwner = new MobileDictationKeepAwakeOwner() + const hungAcquire = hungOwner.acquire('hung') + // Drain microtasks to quiescence so the timeout timer is registered. + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out') + + // The queue must advance, and another owner's drain must spare the + // still-wanted maybe-late activation. + const nextOwner = new MobileDictationKeepAwakeOwner() + await nextOwner.acquire('next') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next') + + // Once its own dictation ends, the orphan gets cleaned. + await hungOwner.release('hung') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength( + 1 + ) + await nextOwner.release('next') + } finally { + vi.useRealTimers() + } + }) + + it('adopts a timed-out activation that lands late while the dictation is live', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('late') + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted, not deactivated: protection stays on for the live dictation. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('late') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength( + 1 + ) + } finally { + vi.useRealTimers() + } + }) + + it('does not let another owner drain a still-wanted timed-out activation', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const ownerA = new MobileDictationKeepAwakeOwner() + const acquireA = ownerA.acquire('wanted') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquireA).rejects.toThrow('Keep-awake native call timed out') + + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + lateActivation.resolve() + await vi.advanceTimersByTimeAsync(0) + // Adopted for owner A; released like a normal activation afterwards. + await ownerA.release('wanted') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted')) + ).toHaveLength(1) + await ownerB.release('other') + } finally { + vi.useRealTimers() + } + }) + + it('deactivates a late-landing activation once its dictation has ended', async () => { + vi.useFakeTimers() + try { + const lateActivation = deferred() + keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('ended') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.release('ended') + lateActivation.resolve() + await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)) + expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retries a timed-out final deactivation via the foreground drain', async () => { + vi.useFakeTimers() + try { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('final') + const tag = keepAwake.activate.mock.calls[0]?.[0] + // The release deactivate times out and its trailing drain retry fails. + keepAwake.deactivate + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockRejectedValueOnce(new Error('Activity unavailable')) + const release = owner.release('final') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(release).rejects.toThrow('Keep-awake native call timed out') + expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) + + await drainMobileDictationKeepAwakeCleanup() + expect(keepAwake.deactivate).toHaveBeenCalledTimes(3) + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + } finally { + vi.useRealTimers() + } + }) + + it('drains orphaned tags on release, not only on the next acquire', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('orphan') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + // The dictation ends without another acquire; release must still clean. + await owner.release('orphan') + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan')) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it('recovers on foreground reacquire after a failed initial acquisition', async () => { + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + const owner = new MobileDictationKeepAwakeOwner() + await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake') + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.reacquire('current') + const tag = keepAwake.activate.mock.calls[1]?.[0] + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + expect(tag).toContain(':current') + // A definite rejection activated nothing, so recovery must not deactivate. + expect(keepAwake.deactivate).not.toHaveBeenCalled() + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('recovers keep-awake on a later reacquire after a failed refresh', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + // Refresh loses the activation: deactivate succeeds, activate rejects. + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('current') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag) + // Only the pre-refresh deactivate ran; recovery must not deactivate again. + expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) + + await owner.release('current') + expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) + }) + + it('records new-dictation intent even when previous-tag cleanup fails', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('first') + + // Release and its trailing drain both fail; the owner keeps stale intent. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(owner.release('first')).rejects.toThrow('Activity unavailable') + + // The next acquire's drain and previous-tag cleanup fail too, and the new + // activation itself fails — intent must still be recorded for the heal. + keepAwake.deactivate + .mockRejectedValueOnce(new Error('Activity unavailable')) + .mockRejectedValueOnce(new Error('Activity unavailable')) + keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) + await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake') + + await owner.reacquire('second') + expect(keepAwake.activate).toHaveBeenCalledTimes(3) + expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second') + + await owner.release('second') + }) + + it('keeps tracking a newer timed-out activation when an older one settles late', async () => { + vi.useFakeTimers() + try { + const first = deferred() + keepAwake.activate.mockImplementationOnce(() => first.promise) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + const second = deferred() + keepAwake.activate.mockImplementationOnce(() => second.promise) + const reacquire = owner.reacquire('stacked') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(reacquire).rejects.toThrow('Keep-awake native call timed out') + + // The older activation settles late with a rejection; the newer + // activation's tracking must survive it. + first.reject(new Error('Activity unavailable')) + await vi.advanceTimersByTimeAsync(0) + + await owner.release('stacked') + // Exactly two: the reacquire's deactivate-first pass, plus the release + // drain cleaning the newer entry the stale settle must not have deleted. + expect( + keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked')) + ).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('reacquires a maybe-active timed-out activation by deactivating first', async () => { + vi.useFakeTimers() + try { + keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) + const owner = new MobileDictationKeepAwakeOwner() + const acquire = owner.acquire('maybe') + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + await expect(acquire).rejects.toThrow('Keep-awake native call timed out') + + await owner.reacquire('maybe') + // The ambiguous activation may be natively live; it must be deactivated + // before the fresh activate so Android re-applies the window flag. + const tag = keepAwake.activate.mock.calls[0]?.[0] + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.release('maybe') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => { + const ownerA = new MobileDictationKeepAwakeOwner() + await ownerA.acquire('live') + const liveTag = keepAwake.activate.mock.calls[0]?.[0] + + // Foreground refresh: the deactivate leg fails; the tag stays native-on + // and the failure surfaces so the caller's bounded retry can kick in. + keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) + await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // Another owner's drain must spare the still-wanted live tag. + const ownerB = new MobileDictationKeepAwakeOwner() + await ownerB.acquire('other') + expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) + + // The next foreground retries the full deactivate-then-activate refresh. + await ownerA.reacquire('live') + expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2) + + await ownerA.release('live') + await ownerB.release('other') + }) + + it('reacquires by deactivating before activating so Android re-applies the window flag', async () => { + const owner = new MobileDictationKeepAwakeOwner() + await owner.acquire('current') + const tag = keepAwake.activate.mock.calls[0]?.[0] + + await owner.reacquire('current') + + expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) + expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]]) + expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( + keepAwake.activate.mock.invocationCallOrder[1] ?? 0 + ) + + await owner.reacquire('other') + expect(keepAwake.activate).toHaveBeenCalledTimes(2) + + await owner.release('current') + }) +}) diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts new file mode 100644 index 00000000000..f94e3fb785a --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -0,0 +1,236 @@ +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' + +const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' + +// Native keep-awake promises can be lost during Activity teardown; a bounded +// wait keeps the serialized queue below from wedging dictation until restart. +export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 + +let nextOwnerId = 0 +let keepAwakeOperation: Promise = Promise.resolve() +const activeTags = new Set() +const pendingCleanupTags = new Set() +// Timed-out activations that may still land natively, keyed to a predicate +// saying whether their dictation still wants the tag. +const pendingActivations = new Map boolean>() + +function createOwnerId(): string { + nextOwnerId += 1 + return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}` +} + +function enqueueKeepAwakeOperation(action: () => Promise): Promise { + const operation = keepAwakeOperation.then(action) + keepAwakeOperation = operation.catch(() => undefined) + return operation +} + +const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError' + +function isNativeCallTimeout(err: unknown): boolean { + return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME +} + +function withNativeCallTimeout(nativeCall: Promise): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const timeoutError = new Error('Keep-awake native call timed out') + timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME + reject(timeoutError) + }, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) + nativeCall.then( + () => { + clearTimeout(timer) + resolve() + }, + (err: unknown) => { + clearTimeout(timer) + reject(err instanceof Error ? err : new Error(String(err))) + } + ) + }) +} + +async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise { + const nativeActivation = activateKeepAwakeAsync(tag) + try { + await withNativeCallTimeout(nativeActivation) + } catch (err) { + // Only a timeout is ambiguous: the activation can still take effect late. + // A definite native rejection activated nothing and needs no cleanup. + if (isNativeCallTimeout(err)) { + // Tracked apart from deactivation retries so drains (including another + // owner's) never turn off an activation its dictation still wants. + pendingActivations.set(tag, isStillWanted) + nativeActivation.then( + () => + void enqueueKeepAwakeOperation(async () => { + // Delete only this activation's own entry — a newer timed-out + // activation of the same tag may have replaced it. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + if (activeTags.has(tag)) { + return + } + if (isStillWanted()) { + // The activation landed late but its dictation is still live; + // adopt it rather than turning off screen-lock protection. + activeTags.add(tag) + return + } + // No owner wants it anymore — the screen must not stay awake. + await deactivateTrackedTag(tag).catch(() => undefined) + }), + () => { + // A late definite rejection means nothing activated after all, but + // spare a newer activation's entry keyed to the same tag. + if (pendingActivations.get(tag) === isStillWanted) { + pendingActivations.delete(tag) + } + } + ) + } + throw err + } + activeTags.add(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function deactivateTrackedTag(tag: string): Promise { + try { + await withNativeCallTimeout(deactivateKeepAwake(tag)) + } catch (err) { + // A replacement hook must be able to retry cleanup after Android replaces + // an Activity and the owner that acquired this tag has unmounted. + pendingCleanupTags.add(tag) + throw err + } + activeTags.delete(tag) + pendingCleanupTags.delete(tag) + pendingActivations.delete(tag) +} + +async function cleanupPendingTags(): Promise { + const staleTags = new Set(pendingCleanupTags) + for (const [tag, isStillWanted] of pendingActivations) { + // A still-wanted timed-out activation is not an orphan: deactivating it + // would turn off screen-lock protection for a live dictation. + if (!isStillWanted()) { + pendingActivations.delete(tag) + staleTags.add(tag) + } + } + if (staleTags.size === 0) { + return + } + // Retry concurrently so N stale tags cost one timeout window, not N, and + // swallow failures: a stale tag that still cannot be deactivated must not + // fail the fresh acquire that triggered this retry; it stays queued. + await Promise.allSettled( + Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined)) + ) +} + +export class MobileDictationKeepAwakeOwner { + private readonly ownerId = createOwnerId() + private acquiredTag: string | null = null + + acquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag && !activeTags.has(this.acquiredTag)) { + this.acquiredTag = null + } + if (this.acquiredTag === tag) { + return + } + if (this.acquiredTag) { + const previousTag = this.acquiredTag + this.acquiredTag = null + // Best-effort: a failed previous-tag cleanup is queued for retry and + // must not block recording intent for the new dictation below. + await deactivateTrackedTag(previousTag).catch(() => undefined) + } + // Record ownership before the native call: acquiredTag is intent while + // activeTags is native state, so a failed initial activation can still + // be healed by a later foreground reacquire. + this.acquiredTag = tag + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated + // Activity silently loses it while native tags persist — and native activate + // skips re-applying the flag while any tag remains, so deactivate first. + reacquire(dictationId: string): Promise { + const tag = this.createTag(dictationId) + return enqueueKeepAwakeOperation(async () => { + await cleanupPendingTags() + if (this.acquiredTag !== tag) { + return + } + // A timed-out activation may be natively active too, and Android only + // re-applies the window flag from an empty tag set — deactivate both. + if (activeTags.has(tag) || pendingActivations.has(tag)) { + try { + await deactivateTrackedTag(tag) + } catch (err) { + // A still-live tag must not sit in the orphan pool where another + // owner's drain would turn it off without reactivating; keep it in + // the wanted pool and surface the failure so the caller can retry + // before the next foreground event. + pendingCleanupTags.delete(tag) + pendingActivations.set(tag, () => this.acquiredTag === tag) + throw err + } + } + // Also recovers an activation lost to an earlier native failure, so a + // later foreground event can restore keep-awake instead of no-oping. + // Known gap: if another expo-keep-awake owner exists (e.g. dev-build + // dev tools), the native module never empties its tag set, so the + // deactivate/activate cycle cannot re-apply the Android window flag. + await activateTrackedTag(tag, () => this.acquiredTag === tag) + }) + } + + release(dictationId?: string): Promise { + const targetTag = dictationId ? this.createTag(dictationId) : null + return enqueueKeepAwakeOperation(async () => { + try { + const tag = this.acquiredTag + if (!tag || (targetTag && tag !== targetTag)) { + return + } + if (!activeTags.has(tag)) { + this.acquiredTag = null + return + } + await deactivateTrackedTag(tag) + this.acquiredTag = null + } finally { + // Drain after the owner-local unset so this owner's own timed-out + // activation is no longer wanted and gets cleaned here — an acquire + // may never happen again this session. Still-wanted tags of other + // live dictations are spared by the drain itself. + await cleanupPendingTags() + } + }) + } + + private createTag(dictationId: string): string { + return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}` + } +} + +export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner { + return new MobileDictationKeepAwakeOwner() +} + +// Foreground is the retry point for wake tags whose final deactivation timed +// out after a dictation ended — otherwise nothing runs until the next one. +export function drainMobileDictationKeepAwakeCleanup(): Promise { + return enqueueKeepAwakeOperation(cleanupPendingTags) +} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts new file mode 100644 index 00000000000..bfc2dbd8d29 --- /dev/null +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -0,0 +1,65 @@ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because +// Metro cannot resolve Node builtins in a React Native bundle. +import { Buffer } from 'buffer' + +import type { RpcClient } from '../transport/rpc-client' + +export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' + +export type UseMobileDictationOptions = { + client: RpcClient | null + enabled: boolean + onTranscript: (text: string) => void + onError?: (error: Error) => void +} + +export type UseMobileDictationResult = { + status: DictationStatus + isStarting: boolean + isRecording: boolean + isProcessing: boolean + error: string | null + start: () => Promise + stop: () => Promise + cancel: () => Promise +} + +export const DICTATION_FINISH_TIMEOUT_MS = 75_000 + +// Recording start waits at most this long for the best-effort wake tag; a +// slow or hung native keep-awake module must not hold the mic in 'starting'. +export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500 + +export function bytesToBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +export function createMobileDictationId(): string { + return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function isCurrentMobileDictationStart( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + dictationId: string +): boolean { + return currentGeneration === generation && enabled && activeId === dictationId +} + +export function isCurrentMobileDictationFinish( + currentGeneration: number, + generation: number, + enabled: boolean, + activeId: string | null, + finishingId: string | null, + dictationId: string +): boolean { + return ( + currentGeneration === generation && + enabled && + activeId === dictationId && + finishingId === dictationId + ) +} diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 6319e2d9b5a..0649e7ee54b 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8') +const audioChunkSource = readFileSync( + new URL('./mobile-dictation-audio-chunk.ts', import.meta.url), + 'utf8' +) +const keepAwakeSource = readFileSync( + new URL('./mobile-dictation-keep-awake.ts', import.meta.url), + 'utf8' +) +const desktopStartSource = readFileSync( + new URL('./mobile-dictation-desktop-start.ts', import.meta.url), + 'utf8' +) +const sessionStateSource = readFileSync( + new URL('./mobile-dictation-session-state.ts', import.meta.url), + 'utf8' +) +const foregroundKeepAwakeSource = readFileSync( + new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url), + 'utf8' +) + +function sliceSource(sourceText: string, startPattern: string, endPattern: string): string { + const start = sourceText.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = sourceText.indexOf(endPattern, start) + expect(end).toBeGreaterThan(start) + return sourceText.slice(start, end) +} function sliceBetween(startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) + return sliceSource(source, startPattern, endPattern) +} + +function sliceDesktopStartBetween(startPattern: string, endPattern: string): string { + return sliceSource(desktopStartSource, startPattern, endPattern) } describe('useMobileDictation source invariants', () => { @@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => { }) it('reserves pending audio bytes before encoding microphone chunks', () => { - const microphoneEffect = sliceBetween( - "addExpoTwoWayAudioEventListener('onMicrophoneData'", - 'return () => sub.remove()' + const microphoneEffect = sliceSource( + audioChunkSource, + 'export function enqueueMobileDictationAudioChunk', + ' queue.pendingChunks.add(sendChunk)' ) const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)') @@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => { expect(encodeIndex).toBeGreaterThanOrEqual(0) expect(reserveIndex).toBeLessThan(encodeIndex) expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE') - expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)') + expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)') + expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event') + }) + + it('reuses audio chunk queue wiring across microphone events', () => { + const queueIndex = source.indexOf('const audioChunkQueue =') + const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'") + + expect(queueIndex).toBeGreaterThanOrEqual(0) + expect(queueIndex).toBeLessThan(listenerIndex) + expect(source).toContain( + 'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)' + ) }) it('resets pending audio bytes whenever pending chunk tracking is cleared', () => { @@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => { expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) }) + + it('keeps mobile dictation keep-awake ownership beside the hook', () => { + expect(source).toMatch( + /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ + ) + expect(source).toContain( + 'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])' + ) + expect(keepAwakeSource).toContain('activateKeepAwakeAsync') + expect(keepAwakeSource).toContain('deactivateKeepAwake') + expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/) + }) + + it('acquires keep-awake only after desktop start and stale-start guards', () => { + const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =') + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const desktopStartIndex = startBody.indexOf( + "client.sendRequest('speech.dictation.start', { dictationId })" + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') + const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)') + + expect(desktopStartIndex).toBeGreaterThanOrEqual(0) + expect(acquireIndex).toBeGreaterThan(desktopStartIndex) + expect(desktopSessionIndex).toBeGreaterThanOrEqual(0) + expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex) + expect(hookStartBody).toContain('commitRecordingStart: () => {') + expect(startBody).toContain('options.commitRecordingStart()') + + const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex) + expect(beforeAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(sessionStateSource).toContain( + 'currentGeneration === generation && enabled && activeId === dictationId' + ) + }) + + it('re-checks stale-start guards after awaited keep-awake acquisition', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + const returnStartedIndex = startBody.indexOf('return true') + const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) + + expect(afterAcquire).toContain('isCurrentStart(options)') + expect(desktopStartSource).toContain('options.getCurrentGeneration()') + expect(desktopStartSource).toContain('options.getEnabled()') + expect(desktopStartSource).toContain('options.getActiveId()') + expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })') + + // The stale-start cleanup must release the wake tag and cancel the desktop + // session (concurrently, so a hung acquisition can't delay the cancel). + const cancelStaleStartBody = sliceDesktopStartBetween( + 'async function cancelStaleStart', + 'export async function startMobileDictationDesktopSession' + ) + expect(cancelStaleStartBody).toContain( + "client.sendRequest('speech.dictation.cancel', { dictationId })" + ) + expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))') + expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)') + }) + + it('treats keep-awake acquisition as best-effort for the desktop session', () => { + const startBody = sliceDesktopStartBetween( + 'export async function startMobileDictationDesktopSession', + ' return true' + ) + const acquireIndex = startBody.indexOf('.acquire(dictationId)') + expect(acquireIndex).toBeGreaterThanOrEqual(0) + const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) + expect(staleCheckIndex).toBeGreaterThan(acquireIndex) + + // An acquisition failure must not cancel the dictation or surface an error. + const acquireChain = startBody.slice(acquireIndex, staleCheckIndex) + expect(acquireChain).toContain('.catch(') + expect(acquireChain).not.toContain('throw') + }) + + it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan( + closeAudio.indexOf('void keepAwakeOwner.release') + ) + expect(closeAudio).toContain('.catch(() => undefined)') + + const cleanupSlices = [ + sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), + sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'), + sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') + ] + + for (const cleanupSlice of cleanupSlices) { + expect(cleanupSlice).toContain('closeDictationAudio(dictationId)') + } + + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan( + stopBody.indexOf('await Promise.allSettled') + ) + // The wake tag must be held through chunk drain and the finish RPC so a + // screen lock cannot suspend the app before the transcript arrives. + expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + expect(stopBody.indexOf('} finally {')).toBeLessThan( + stopBody.indexOf('void keepAwakeOwner.release') + ) + }) + + it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => { + expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)') + expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'") + expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)') + // A transiently failing refresh retries while the dictation is live. + expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]') + expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId') + // Stale-tag retries survive hook unmount via a module-level listener. + expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()') + expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()') + + // Native activate skips re-applying the window flag while any tag remains, + // so reacquire must deactivate before activating. + const reacquireBody = sliceSource( + keepAwakeSource, + 'reacquire(dictationId: string)', + 'release(dictationId?: string)' + ) + expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0) + expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan( + reacquireBody.indexOf('await activateTrackedTag(tag,') + ) + }) + + it('keeps cleanup going when native recording shutdown throws', () => { + const closeAudio = sliceBetween( + 'const closeDictationAudio = useCallback(', + 'const failActiveDictation =' + ) + const toggleIndex = closeAudio.indexOf('toggleRecording(false)') + const catchIndex = closeAudio.indexOf('} catch', toggleIndex) + const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release') + expect(toggleIndex).toBeGreaterThanOrEqual(0) + expect(catchIndex).toBeGreaterThan(toggleIndex) + expect(catchIndex).toBeLessThan(releaseIndex) + + // stop()'s recording shutdown sits inside the try so a native throw still + // runs the finally release and error cleanup. + const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') + expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0) + expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)')) + }) + + it('routes disabled state and audio interruptions through cancel cleanup', () => { + const interruptionEffect = sliceBetween( + "addExpoTwoWayAudioEventListener('onAudioInterruption'", + 'return () => sub.remove()' + ) + const disabledEffect = sliceBetween( + 'useEffect(() => {\n if (!enabled) {', + ' }, [cancel, enabled])' + ) + + expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'") + expect(interruptionEffect).toContain('void cancel()') + expect(disabledEffect).toContain('void cancel()') + }) + + it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => { + expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()') + expect(keepAwakeSource).toContain( + '`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`' + ) + expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise = Promise.resolve()') + expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set()') + expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3) + expect(keepAwakeSource).toContain( + 'const targetTag = dictationId ? this.createTag(dictationId) : null' + ) + expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))') + expect(keepAwakeSource).toContain('await cleanupPendingTags()') + }) }) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index a1028cf9205..648048dc451 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,7 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { addExpoTwoWayAudioEventListener, initialize, @@ -9,45 +6,27 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import type { RpcClient } from '../transport/rpc-client' +import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' +import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk' +import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' +import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake' import { - MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, - MOBILE_DICTATION_PCM_SAMPLE_RATE, - MobileDictationPendingAudioBudget -} from './mobile-dictation-pending-audio-budget' + DICTATION_FINISH_TIMEOUT_MS, + createMobileDictationId, + isCurrentMobileDictationFinish +} from './mobile-dictation-session-state' +import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' +import type { + DictationStatus, + UseMobileDictationOptions, + UseMobileDictationResult +} from './mobile-dictation-session-state' -type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error' - -type UseMobileDictationOptions = { - client: RpcClient | null - enabled: boolean - onTranscript: (text: string) => void - onError?: (error: Error) => void -} - -export type UseMobileDictationResult = { - status: DictationStatus - isStarting: boolean - isRecording: boolean - isProcessing: boolean - error: string | null - start: () => Promise - stop: () => Promise - cancel: () => Promise -} - -const DICTATION_FINISH_TIMEOUT_MS = 75_000 - -function bytesToBase64(bytes: Uint8Array): string { - return Buffer.from(bytes).toString('base64') -} - -function createDictationId(): string { - return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}` -} +export type { UseMobileDictationResult } from './mobile-dictation-session-state' export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options + const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, []) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) @@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil onErrorRef.current?.(normalized) }, []) + const closeDictationAudio = useCallback( + (dictationId?: string | null) => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + try { + toggleRecording(false) + } catch (err) { + // Cleanup must keep going when native recording shutdown throws, or + // the wake tag and dictation state would leak. + console.error('Failed to stop microphone recording', err) + } + void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) + }, + [keepAwakeOwner] + ) + const failActiveDictation = useCallback( (dictationId: string, err: unknown) => { const client = clientRef.current @@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } activeIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } reportError(err) }, - [reportError] + [closeDictationAudio, reportError] ) useEffect(() => { + // Microphone events are a hot path; reuse this wiring instead of allocating + // a queue object and release predicate for every audio chunk. + const audioChunkQueue = { + pendingChunks: pendingChunksRef.current, + pendingAudioBudget: pendingAudioBudgetRef.current, + shouldReleaseBudget: (id: string) => + activeIdRef.current === id || finishingIdRef.current === id, + failActiveDictation + } const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const client = clientRef.current const dictationId = activeIdRef.current if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) { return } - const raw = event.data - const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) - const byteLength = bytes.byteLength - if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) { - failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)) - return - } - const sendChunk = client - .sendRequest('speech.dictation.chunk', { - dictationId, - audioBase64: bytesToBase64(bytes), - sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE - }) - .then((response) => { - if (!response.ok) { - throw new Error(response.error.message) - } - }) - .catch((err) => failActiveDictation(dictationId, err)) - .finally(() => { - if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) { - pendingAudioBudgetRef.current.release(byteLength) - } - pendingChunksRef.current.delete(sendChunk) - }) - pendingChunksRef.current.add(sendChunk) + enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue) }) return () => sub.remove() }, [failActiveDictation, reportError]) @@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error('Failed to initialize microphone') } - const dictationId = createDictationId() + const dictationId = createMobileDictationId() activeIdRef.current = dictationId - try { - const response = await client.sendRequest('speech.dictation.start', { dictationId }) - if (!response.ok) { - throw new Error(response.error.message) - } - } catch (err) { - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - setStatus('idle') - throw err - } - if ( - generationRef.current !== generation || - !enabledRef.current || - activeIdRef.current !== dictationId - ) { - await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) - if (activeIdRef.current === dictationId) { - activeIdRef.current = null - } - setStatus('idle') - return - } - acceptingChunksRef.current = true - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(true) - setStatus('recording') - }, []) + await startMobileDictationDesktopSession({ + client, + dictationId, + generation, + getCurrentGeneration: () => generationRef.current, + getEnabled: () => enabledRef.current, + getActiveId: () => activeIdRef.current, + clearActiveId: (id) => { + if (activeIdRef.current === id) { + activeIdRef.current = null + } + }, + setIdle: () => setStatus('idle'), + keepAwakeOwner, + commitRecordingStart: () => { + acceptingChunksRef.current = true + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + if (!toggleRecording(true)) { + return false + } + setStatus('recording') + return true + }, + rollbackRecordingStart: () => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + toggleRecording(false) + } + }) + }, [keepAwakeOwner]) const stop = useCallback(async () => { const client = clientRef.current @@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil finishingIdRef.current = dictationId setStatus('processing') acceptingChunksRef.current = false - toggleRecording(false) try { + // Inside the try so a throwing native shutdown still runs the finally + // release and error cleanup. + toggleRecording(false) await Promise.allSettled(Array.from(pendingChunksRef.current)) if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil throw new Error(response.error.message) } if ( - generationRef.current !== generation || - activeIdRef.current !== dictationId || - finishingIdRef.current !== dictationId || - !enabledRef.current + !isCurrentMobileDictationFinish( + generationRef.current, + generation, + enabledRef.current, + activeIdRef.current, + finishingIdRef.current, + dictationId + ) ) { return } @@ -257,11 +245,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } catch (err) { failActiveDictation(dictationId, err) } finally { + // Hold the wake tag through chunk drain and the finish RPC: a screen + // lock mid-processing suspends the app and loses the transcript. + void keepAwakeOwner.release(dictationId).catch(() => undefined) if (finishingIdRef.current === dictationId) { finishingIdRef.current = null } } - }, [failActiveDictation]) + }, [failActiveDictation, keepAwakeOwner]) const cancel = useCallback(async () => { const client = clientRef.current @@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) if (client && dictationId) { await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined) } setStatus('idle') setError(null) - }, []) + }, [closeDictationAudio]) + + useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef) useEffect(() => { const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => { @@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil generationRef.current += 1 activeIdRef.current = null finishingIdRef.current = null - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - toggleRecording(false) + closeDictationAudio(dictationId) void tearDown() if (clientRef.current && dictationId) { void clientRef.current @@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil .catch(() => undefined) } } - }, []) + }, [closeDictationAudio]) return { status, From 31cff75a7ba00e0cca5051893ebff48bea5d2a10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:34:49 -0700 Subject: [PATCH 94/95] feat(agent-status): show Claude subagent child rows and gate premature done (#8211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): show Claude subagent child rows and gate premature done A Claude pane that spawned background subagents/teammates showed a green done check the moment the lead's turn ended, even while a background review loop was still running. Orca now tracks the pane's live children from Claude hook events and: - keeps the pane 'working' while at least one child is working (Stop is gated; Claude wakes the lead when a child finishes, so the pane resolves to done on the follow-up Stop with an empty roster) - renders the children as indented child rows under the pane's sidebar row (name/type + working/idle dot), reusing the existing lineage UI Tracking is lifecycle-primary: SubagentStart/SubagentStop/TeammateIdle (newly registered hooks) plus child-origin tool events (they carry agent_id) own the roster. Stop's background_tasks is folded only where unambiguous — verified live on Claude Code 2.1.207 that teammates report status "running" while idle-alive and their task ids never match lifecycle agent_ids, so the list cannot decide teammate working-ness. Child-origin events no longer overwrite the lead's tool/prompt caches (a live AskUserQuestion card survives child churn); a child's own PermissionRequest records waitingAgentId so only that child's progress or death clears the wait. The interrupted flag survives the gated window, inferred interrupts sync the lead record and refuse while a child works, and hydration reseeds the roster after a restart. * fix(agent-status): drop identity icon on subagent child rows The child's agentType carries its NAME (e.g. "pr-reviewer"), which is not an iconable agent and rendered the unknown "?" glyph. Nesting under the parent row already conveys identity. * fix(agent-status): restore displaced lead state and reconcile phantom subagents Four review findings from the adversarial pass on the subagent child-row feature: - Stash the lead state a child-induced wait displaces (ClaudeLeadTurnState.stateBeforeWait) and restore it when the wait clears, instead of inventing 'working' — a lead that had already stopped left the pane spinning forever after the roster drained, since the done-gate only ever downgrades done → working. - Tag snapshot-seeded and background_tasks-recreated roster entries (backgroundTasksAuthoritative) and demote them when a PRESENT background_tasks list omits their id. A phantom child seeded before a restart could otherwise gate the pane 'working' indefinitely in teams sessions, whose task list is never empty. Live activity clears the tag so lifecycle-tracked teammates keep their state. - Match teammate ids with a hyphen-free suffix after `a-` so TeammateIdle for "lane" cannot idle "lane-hooks"'s rows or clear its pending permission wait. - Route turn-boundary events (Stop/StopFailure/UserPromptSubmit) that carry a KNOWN child agent_id through the child-driven re-emit instead of adopting them as lead state, and tie the prompt-cache new-turn reset to lead-origin events so child refreshes can't blank the prompt label. --------- Co-authored-by: Brennan Benson --- src/main/agent-hooks/server.test.ts | 83 ++++ src/main/agent-hooks/server.ts | 35 +- src/main/claude/hook-service.test.ts | 3 + src/main/claude/hook-settings.ts | 8 + .../dashboard/DashboardAgentRow.tsx | 20 +- .../DashboardAgentRowTrailingControls.tsx | 16 +- .../components/dashboard/useDashboardData.ts | 7 +- .../sidebar/useWorktreeAgentRows.test.ts | 72 +++ .../components/sidebar/worktree-agent-rows.ts | 3 + .../worktree-card-compact-agent-row.tsx | 17 +- .../sidebar/worktree-subagent-child-rows.ts | 63 +++ src/renderer/src/hooks/useIpcEvents.ts | 5 +- src/renderer/src/store/slices/agent-status.ts | 8 + src/shared/agent-hook-listener.test.ts | 463 ++++++++++++++++++ src/shared/agent-hook-listener.ts | 357 +++++++++++++- .../agent-status-field-normalization.ts | 213 ++++++++ src/shared/agent-status-types.test.ts | 54 ++ src/shared/agent-status-types.ts | 279 ++++------- src/shared/claude-subagent-roster.test.ts | 261 ++++++++++ src/shared/claude-subagent-roster.ts | 257 ++++++++++ 20 files changed, 2004 insertions(+), 220 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts create mode 100644 src/shared/agent-status-field-normalization.ts create mode 100644 src/shared/claude-subagent-roster.test.ts create mode 100644 src/shared/claude-subagent-roster.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index bf3cf0c8f84..9fbb358dfae 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -143,6 +143,89 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not infer an interrupt while a subagent child is still working', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'review loop', + agentType: 'claude', + // Why: a working pane can be child-driven (lead already idle). + // Ctrl+C does not stop background children, so no terminal done + // may be inferred while one is still running. + subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'review loop', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + } finally { + vi.useRealTimers() + } + }) + + it('carries idle subagent rows through an inferred interrupt', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + prompt: 'wrap up', + agentType: 'claude', + subagents: [{ id: 'a1', state: 'idle', startedAt: 900, agentType: 'probe1' }] + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + const applied = server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'wrap up', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + + expect(applied).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + subagents: [expect.objectContaining({ id: 'a1', state: 'idle' })] + }) + } finally { + vi.useRealTimers() + } + }) + it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index da33b4231cb..45fdf0362a6 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -26,12 +26,14 @@ import { getEndpointFileName, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, + markClaudeLeadTurnInterrupted, MAX_PANE_KEY_LEN, normalizeHookPayload, parseFormEncodedBody, readRequestBody, resolveHookSource, preparePendingGrokResultDiscovery, + seedClaudeSubagentRosterFromSnapshots, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -253,6 +255,10 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } +// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`: +// OSC payloads never carry them, and including the field would make every +// hook-cached entry with child rows non-equivalent — the OSC ping would then +// apply and wipe the roster. Do not reuse this for hook-path comparisons. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, b: ParsedAgentStatusPayload @@ -555,7 +561,20 @@ export class AgentHookServer { ) { return false } + // Why: a 'working' pane can be child-driven (lead already idle, background + // subagent running). Ctrl+C at the TUI does not stop background children, + // so inferring a terminal done here would wrongly retire live child rows; + // their own hook events keep the row truthful instead. + if (payload.subagents?.some((subagent) => subagent.state === 'working')) { + return false + } + // Why: keep the listener's Claude lead-turn record in sync — a later + // child lifecycle event would otherwise re-emit the stale pre-interrupt + // 'working' lead state and resurrect the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -566,7 +585,10 @@ export class AgentHookServer { state: 'done', prompt: payload.prompt, agentType, - interrupted: true + interrupted: true, + // Why: idle children are display state; dropping them on an inferred + // interrupt would blank the child rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) console.debug('[agent-hooks] inferred interrupted agent status', { @@ -1558,6 +1580,17 @@ export class AgentHookServer { const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + // Why: the in-memory subagent roster died with the previous process. + // Reseed it from the persisted snapshot so the next teammate-bearing + // Stop (whose task ids never match lifecycle ids) doesn't silently + // drop the replayed child rows. + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } hydrated += 1 } else { dropped += 1 diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index ed77f48e6e0..6400efe7357 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -255,6 +255,9 @@ describe('ClaudeHookService.installRemote', () => { 'UserPromptSubmit', 'Stop', 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'TeammateIdle', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index cb754fce5d0..0c7012b383a 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -32,6 +32,14 @@ export const CLAUDE_EVENTS = [ // Why: OpenClaude skips normal Stop hooks after API/model errors and emits // StopFailure instead; without this hook Orca leaves the turn spinning. { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps + // a pane 'working' while background children outlive the lead's turn. + // TeammateIdle is required because idle-but-alive teammates still report + // status "running" in Stop's background_tasks. + // Older Claude builds ignore unregistered event names (StopFailure precedent). + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'TeammateIdle', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: PreToolUse gives the dashboard a live readout of the in-flight tool // (name + input preview) before it completes. { diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 692fdb0279c..1a84a985ba5 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -50,6 +50,12 @@ function formatTimeAgo(ts: number, now: number): string { // drift away from the true transition moment. For past dones, stateHistory // entries already store the per-transition `startedAt` so we read it directly. function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { + // Why: idle subagent child rows are alive-but-idle (teammates persist + // between turns) — reading their synthetic entry as "done Xm ago" would + // mislabel a live teammate as finished. + if (agent.rowSource === 'subagent' && agent.state === 'idle') { + return null + } const entry = agent.entry if (entry.state === 'done') { return entry.stateStartedAt @@ -163,9 +169,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const handleActivate = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - onActivate(agent.tab.id, agent.paneKey) + // Why: subagent child rows have no pane of their own; they focus the + // parent pane whose session spawned them. + onActivate(agent.tab.id, agent.activationPaneKey ?? agent.paneKey) }, - [onActivate, agent.tab.id, agent.paneKey] + [onActivate, agent.tab.id, agent.activationPaneKey, agent.paneKey] ) const handleSendTargetClickCapture = useCallback( (e: React.MouseEvent) => { @@ -332,8 +340,11 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ on the top row. The sub-rows (tool step, assistant response) are about the same agent and do not need the icon repeated next to them — keeping the icon only on the prompt row lets the sub-rows - indent under the prompt text cleanly. */} - {!hideIdentityIcon && ( + indent under the prompt text cleanly. Subagent child rows carry + the child's NAME in agentType (not an iconable agent — it would + render the unknown "?" glyph), and nesting under the parent + already conveys identity. */} + {!hideIdentityIcon && agent.rowSource !== 'subagent' && ( @@ -385,6 +396,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ relativeTimestamp={relativeTimestamp} expanded={expanded} hideExpand={hideExpand} + hideDismiss={agent.rowSource === 'subagent'} sendTargetStatus={sendTargetStatus} onDismiss={onDismiss} onToggleExpanded={handleToggleExpanded} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx index 13b3bafae6f..f194abbe508 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRowTrailingControls.tsx @@ -8,6 +8,9 @@ type DashboardAgentRowTrailingControlsProps = { relativeTimestamp: string | null expanded: boolean hideExpand: boolean + /** Subagent child rows have no store entry of their own to dismiss — + * offering the X would be a silent no-op. */ + hideDismiss?: boolean sendTargetStatus?: 'eligible' | 'disabled' | 'sending' onDismiss: (paneKey: string) => void onToggleExpanded: () => void @@ -19,6 +22,7 @@ export function DashboardAgentRowTrailingControls({ relativeTimestamp, expanded, hideExpand, + hideDismiss = false, sendTargetStatus, onDismiss, onToggleExpanded, @@ -86,9 +90,17 @@ export function DashboardAgentRowTrailingControls({ {translate('auto.components.dashboard.DashboardAgentRow.912e136cd9', 'Send')} )} + {!sendTargetStatus && hideDismiss && relativeTimestamp !== null && ( + + {relativeTimestamp} + + )} {/* Why: timestamp and dismiss-X share one slot. On no-hover devices the X is visible by default, so the timestamp must yield there too. */} - {!sendTargetStatus && relativeTimestamp !== null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp !== null && ( )} - {!sendTargetStatus && relativeTimestamp === null && ( + {!sendTargetStatus && !hideDismiss && relativeTimestamp === null && (