From 5c1c3dd0e3981d00485c2d0744c95a32488a6274 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 20:49:43 +0800 Subject: [PATCH 01/22] Fix agent terminal profile recovery --- docs/TERMINAL_TUI_RENDERING_BASELINE.md | 3 +- .../handlers/sessionHandlers.ts | 105 +---- .../handlers/sessionLaunchSupport.ts | 117 ++++++ .../shell/hooks/useHydrateAppState.ts | 1 + .../agent/presentation/main-ipc/register.ts | 49 ++- .../agent/presentation/main-ipc/validate.ts | 2 + .../presentation/renderer/hydrateAgentNode.ts | 24 +- .../workspaceCanvas/hooks/useAgentLauncher.ts | 3 + .../hooks/useAgentNodeLifecycle.ts | 6 + .../hooks/useCanvasAgentSupport.ts | 1 + .../useTaskActions.agentSession.resume.ts | 3 + .../hooks/useTaskActions.agentSession.run.ts | 3 + .../terminal/TerminalProfileResolver.ts | 384 +++++------------- .../TerminalProfileResolver.windows.ts | 292 +++++++++++++ src/shared/contracts/dto/agent.ts | 4 + .../workspace-canvas.terminal-wheel.spec.ts | 64 +++ .../useHydrateAppState.agentSession.spec.tsx | 66 +++ .../controlSurface.sessionHandlers.spec.ts | 16 +- .../contexts/terminalProfileResolver.spec.ts | 72 ++++ .../workspaceCanvas.runDefaultAgent.spec.tsx | 3 + 20 files changed, 844 insertions(+), 374 deletions(-) create mode 100644 src/app/main/controlSurface/handlers/sessionLaunchSupport.ts create mode 100644 src/platform/terminal/TerminalProfileResolver.windows.ts diff --git a/docs/TERMINAL_TUI_RENDERING_BASELINE.md b/docs/TERMINAL_TUI_RENDERING_BASELINE.md index 13a56b31..682bb9f2 100644 --- a/docs/TERMINAL_TUI_RENDERING_BASELINE.md +++ b/docs/TERMINAL_TUI_RENDERING_BASELINE.md @@ -73,10 +73,11 @@ pnpm test:e2e - `keeps terminal visible after drag, resize, and node interactions` - `keeps agent tui visible while dragging window` - `wheel over terminal scrolls terminal viewport` +- `wheel over a hydrated agent node scrolls the viewport instead of the canvas` - `preserves terminal history after app reload` 推荐快速执行: ```bash -pnpm test:e2e -- tests/e2e/workspace-canvas.spec.ts -g "keeps terminal visible after drag, resize, and node interactions|keeps agent tui visible while dragging window|wheel over terminal scrolls terminal viewport|preserves terminal history after app reload" +pnpm test:e2e -- tests/e2e/workspace-canvas.spec.ts -g "keeps terminal visible after drag, resize, and node interactions|keeps agent tui visible while dragging window|wheel over terminal scrolls terminal viewport|wheel over a hydrated agent node scrolls the viewport instead of the canvas|preserves terminal history after app reload" ``` diff --git a/src/app/main/controlSurface/handlers/sessionHandlers.ts b/src/app/main/controlSurface/handlers/sessionHandlers.ts index f6776d20..e38b725f 100644 --- a/src/app/main/controlSurface/handlers/sessionHandlers.ts +++ b/src/app/main/controlSurface/handlers/sessionHandlers.ts @@ -1,4 +1,3 @@ -import { createServer } from 'node:net' import type { ControlSurface } from '../controlSurface' import type { PersistenceStore } from '../../../../platform/persistence/sqlite/PersistenceStore' import { normalizePersistedAppState } from '../../../../platform/persistence/sqlite/normalize' @@ -6,7 +5,6 @@ import type { ApprovedWorkspaceStore } from '../../../../contexts/workspace/infr import { createAppError } from '../../../../shared/errors/appError' import type { PtyRuntime } from '../../../../contexts/terminal/presentation/main-ipc/runtime' import { buildAgentLaunchCommand } from '../../../../contexts/agent/infrastructure/cli/AgentCommandFactory' -import { resolveAgentCliInvocation } from '../../../../contexts/agent/infrastructure/cli/AgentCliInvocation' import { locateAgentResumeSessionId } from '../../../../contexts/agent/infrastructure/cli/AgentSessionLocator' import { readLastAssistantMessageFromOpenCodeSession, @@ -14,17 +12,13 @@ import { } from '../../../../contexts/agent/infrastructure/watchers/SessionLastAssistantMessage' import { resolveSessionFilePath } from '../../../../contexts/agent/infrastructure/watchers/SessionFileResolver' import { ensureOpenCodeEmbeddedTuiConfigPath } from '../../../../contexts/agent/infrastructure/opencode/OpenCodeTuiConfig' -import { resolveLocalWorkerEndpointRef } from '../../../../contexts/project/application/resolveLocalWorkerEndpointRef' import { resolveSpaceWorkingDirectory } from '../../../../contexts/space/application/resolveSpaceWorkingDirectory' -import { toFileUri } from '../../../../contexts/filesystem/domain/fileUri' import { normalizeAgentSettings, resolveAgentModel, - type AgentProvider, } from '../../../../contexts/settings/domain/agentSettings' import type { AgentProviderId, - ExecutionContextDto, GetSessionFinalMessageInput, GetSessionFinalMessageResult, GetSessionInput, @@ -32,6 +26,12 @@ import type { LaunchAgentSessionInput, LaunchAgentSessionResult, } from '../../../../shared/contracts/dto' +import { + reserveLoopbackPort, + resolveExecutionContextDto, + resolveProviderFromSettings, + resolveSessionLaunchSpawn, +} from './sessionLaunchSupport' const OPENCODE_SERVER_HOSTNAME = '127.0.0.1' const RESUME_SESSION_LOCATE_TIMEOUT_MS = 3_000 @@ -74,31 +74,6 @@ function normalizeAgentProviderId(value: unknown): AgentProviderId | null { }) } -async function reserveLoopbackPort(hostname: string): Promise { - return await new Promise((resolve, reject) => { - const server = createServer() - server.unref() - - server.once('error', reject) - server.listen(0, hostname, () => { - const address = server.address() - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Failed to reserve local loopback port'))) - return - } - - server.close(error => { - if (error) { - reject(error) - return - } - - resolve(address.port) - }) - }) - }) -} - function normalizeLaunchAgentPayload(payload: unknown): LaunchAgentSessionInput { if (!isRecord(payload)) { throw createAppError('common.invalid_input', { @@ -203,44 +178,6 @@ type SessionRecord = GetSessionResult & { startedAtMs: number } -function resolveExecutionContextDto(workingDirectory: string): ExecutionContextDto { - const endpoint = resolveLocalWorkerEndpointRef() - const rootUri = toFileUri(workingDirectory) - - return { - endpoint: { - id: endpoint.id, - kind: endpoint.kind, - }, - target: { - scheme: 'file', - rootPath: workingDirectory, - rootUri, - }, - scope: { - rootPath: workingDirectory, - rootUri, - }, - workingDirectory, - } -} - -function resolveProviderFromSettings( - requestedProvider: string | null, - settings: ReturnType, -): AgentProvider { - if ( - requestedProvider === 'claude-code' || - requestedProvider === 'codex' || - requestedProvider === 'opencode' || - requestedProvider === 'gemini' - ) { - return requestedProvider - } - - return settings.defaultProvider -} - export function registerSessionHandlers( controlSurface: ControlSurface, deps: { @@ -311,11 +248,6 @@ export function registerSessionHandlers( opencodeServer, }) - const resolvedInvocation = await resolveAgentCliInvocation({ - command: launchCommand.command, - args: launchCommand.args, - }) - const startedAtMs = Date.now() const startedAt = new Date(startedAtMs).toISOString() @@ -325,20 +257,27 @@ export function registerSessionHandlers( const sessionEnv = opencodeServer && provider === 'opencode' ? { - ...process.env, OPENCOVE_OPENCODE_SERVER_HOSTNAME: opencodeServer.hostname, OPENCOVE_OPENCODE_SERVER_PORT: String(opencodeServer.port), ...(opencodeTuiConfigPath ? { OPENCODE_TUI_CONFIG: opencodeTuiConfigPath } : {}), } : undefined + const resolvedSpawn = await resolveSessionLaunchSpawn({ + workingDirectory, + defaultTerminalProfileId: agentSettings.defaultTerminalProfileId, + command: launchCommand.command, + args: launchCommand.args, + ...(sessionEnv ? { env: sessionEnv } : {}), + }) + const { sessionId } = await deps.ptyRuntime.spawnSession({ - cwd: workingDirectory, + cwd: resolvedSpawn.cwd, cols: 80, rows: 24, - command: resolvedInvocation.command, - args: resolvedInvocation.args, - ...(sessionEnv ? { env: sessionEnv } : {}), + command: resolvedSpawn.command, + args: resolvedSpawn.args, + ...(resolvedSpawn.env ? { env: resolvedSpawn.env } : {}), }) const executionContext = resolveExecutionContextDto(workingDirectory) @@ -354,8 +293,8 @@ export function registerSessionHandlers( executionContext, resumeSessionId: null, startedAtMs, - command: resolvedInvocation.command, - args: resolvedInvocation.args, + command: resolvedSpawn.command, + args: resolvedSpawn.args, } sessions.set(sessionId, record) @@ -367,8 +306,8 @@ export function registerSessionHandlers( executionContext, resumeSessionId: null, effectiveModel: launchCommand.effectiveModel, - command: resolvedInvocation.command, - args: resolvedInvocation.args, + command: resolvedSpawn.command, + args: resolvedSpawn.args, } }, defaultErrorCode: 'agent.launch_failed', diff --git a/src/app/main/controlSurface/handlers/sessionLaunchSupport.ts b/src/app/main/controlSurface/handlers/sessionLaunchSupport.ts new file mode 100644 index 00000000..42a42701 --- /dev/null +++ b/src/app/main/controlSurface/handlers/sessionLaunchSupport.ts @@ -0,0 +1,117 @@ +import { createServer } from 'node:net' +import process from 'node:process' +import { resolveAgentCliInvocation } from '../../../../contexts/agent/infrastructure/cli/AgentCliInvocation' +import { resolveLocalWorkerEndpointRef } from '../../../../contexts/project/application/resolveLocalWorkerEndpointRef' +import { toFileUri } from '../../../../contexts/filesystem/domain/fileUri' +import { TerminalProfileResolver } from '../../../../platform/terminal/TerminalProfileResolver' +import { + normalizeAgentSettings, + type AgentProvider, +} from '../../../../contexts/settings/domain/agentSettings' +import type { ExecutionContextDto } from '../../../../shared/contracts/dto' + +const terminalProfileResolver = new TerminalProfileResolver() + +export async function reserveLoopbackPort(hostname: string): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.unref() + + server.once('error', reject) + server.listen(0, hostname, () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to reserve local loopback port'))) + return + } + + server.close(error => { + if (error) { + reject(error) + return + } + + resolve(address.port) + }) + }) + }) +} + +export function resolveExecutionContextDto(workingDirectory: string): ExecutionContextDto { + const endpoint = resolveLocalWorkerEndpointRef() + const rootUri = toFileUri(workingDirectory) + + return { + endpoint: { + id: endpoint.id, + kind: endpoint.kind, + }, + target: { + scheme: 'file', + rootPath: workingDirectory, + rootUri, + }, + scope: { + rootPath: workingDirectory, + rootUri, + }, + workingDirectory, + } +} + +export function resolveProviderFromSettings( + requestedProvider: string | null, + settings: ReturnType, +): AgentProvider { + if ( + requestedProvider === 'claude-code' || + requestedProvider === 'codex' || + requestedProvider === 'opencode' || + requestedProvider === 'gemini' + ) { + return requestedProvider + } + + return settings.defaultProvider +} + +interface ResolveSessionLaunchSpawnInput { + workingDirectory: string + defaultTerminalProfileId?: string | null + command: string + args: string[] + env?: NodeJS.ProcessEnv +} + +interface ResolvedSessionLaunchSpawn { + command: string + args: string[] + cwd: string + env?: NodeJS.ProcessEnv +} + +export async function resolveSessionLaunchSpawn( + input: ResolveSessionLaunchSpawnInput, +): Promise { + if (input.defaultTerminalProfileId && input.defaultTerminalProfileId.trim().length > 0) { + return await terminalProfileResolver.resolveCommandSpawn({ + cwd: input.workingDirectory, + profileId: input.defaultTerminalProfileId, + command: input.command, + args: input.args, + ...(input.env ? { env: input.env } : {}), + }) + } + + const resolvedInvocation = await resolveAgentCliInvocation({ + command: input.command, + args: input.args, + }) + + return { + command: resolvedInvocation.command, + args: resolvedInvocation.args, + cwd: input.workingDirectory, + env: input.env ? { ...process.env, ...input.env } : undefined, + } +} diff --git a/src/app/renderer/shell/hooks/useHydrateAppState.ts b/src/app/renderer/shell/hooks/useHydrateAppState.ts index 2669560c..68cf47bc 100644 --- a/src/app/renderer/shell/hooks/useHydrateAppState.ts +++ b/src/app/renderer/shell/hooks/useHydrateAppState.ts @@ -158,6 +158,7 @@ export async function hydrateRuntimeNode({ node, workspacePath, agentFullAccess, + defaultTerminalProfileId, }) } diff --git a/src/contexts/agent/presentation/main-ipc/register.ts b/src/contexts/agent/presentation/main-ipc/register.ts index 7a360c18..dad3d682 100644 --- a/src/contexts/agent/presentation/main-ipc/register.ts +++ b/src/contexts/agent/presentation/main-ipc/register.ts @@ -28,6 +28,7 @@ import { } from '../../infrastructure/watchers/SessionLastAssistantMessage' import { resolveSessionFilePath } from '../../infrastructure/watchers/SessionFileResolver' import { ensureOpenCodeEmbeddedTuiConfigPath } from '../../infrastructure/opencode/OpenCodeTuiConfig' +import { TerminalProfileResolver } from '../../../../platform/terminal/TerminalProfileResolver' import type { PtyRuntime } from '../../../terminal/presentation/main-ipc/runtime' import type { ApprovedWorkspaceStore } from '../../../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore' import { @@ -43,6 +44,7 @@ const HYDRATE_RESUME_RESOLVE_TIMEOUT_MS = 3_000 const READ_LAST_MESSAGE_RESOLVE_TIMEOUT_MS = 1_500 const READ_LAST_MESSAGE_FILE_TIMEOUT_MS = 1_500 const OPENCODE_SERVER_HOSTNAME = '127.0.0.1' +const terminalProfileResolver = new TerminalProfileResolver() function normalizeOptionalEnvValue(value: string | undefined): string | null { const normalized = value?.trim() @@ -224,10 +226,8 @@ export function registerAgentIpcHandlers( : undefined const launchStartedAtMs = Date.now() - const resolvedInvocation = await resolveAgentCliInvocation({ - command: testStub?.command ?? launchCommand.command, - args: testStub?.args ?? launchCommand.args, - }) + const command = testStub?.command ?? launchCommand.command + const args = testStub?.args ?? launchCommand.args const opencodeTuiConfigPath = normalized.provider === 'opencode' @@ -238,20 +238,45 @@ export function registerAgentIpcHandlers( const sessionEnv = opencodeServer && normalized.provider === 'opencode' ? { - ...process.env, OPENCOVE_OPENCODE_SERVER_HOSTNAME: opencodeServer.hostname, OPENCOVE_OPENCODE_SERVER_PORT: String(opencodeServer.port), ...(opencodeTuiConfigPath ? { OPENCODE_TUI_CONFIG: opencodeTuiConfigPath } : {}), } : undefined + const resolvedSpawn = + normalized.profileId && normalized.profileId.trim().length > 0 + ? await terminalProfileResolver.resolveCommandSpawn({ + cwd: normalized.cwd, + profileId: normalized.profileId, + command, + args, + ...(sessionEnv ? { env: sessionEnv } : {}), + }) + : await (async () => { + const resolvedInvocation = await resolveAgentCliInvocation({ + command, + args, + }) + + return { + command: resolvedInvocation.command, + args: resolvedInvocation.args, + cwd: normalized.cwd, + env: sessionEnv ? { ...process.env, ...sessionEnv } : undefined, + profileId: null, + runtimeKind: + process.platform === 'win32' ? ('windows' as const) : ('posix' as const), + } + })() + const { sessionId } = await ptyRuntime.spawnSession({ - cwd: normalized.cwd, + cwd: resolvedSpawn.cwd, cols: normalized.cols ?? 80, rows: normalized.rows ?? 24, - command: resolvedInvocation.command, - args: resolvedInvocation.args, - ...(sessionEnv ? { env: sessionEnv } : {}), + command: resolvedSpawn.command, + args: resolvedSpawn.args, + ...(resolvedSpawn.env ? { env: resolvedSpawn.env } : {}), }) const resumeSessionId = launchCommand.resumeSessionId @@ -278,8 +303,10 @@ export function registerAgentIpcHandlers( const result: LaunchAgentResult = { sessionId, provider: normalized.provider, - command: resolvedInvocation.command, - args: resolvedInvocation.args, + profileId: resolvedSpawn.profileId, + runtimeKind: resolvedSpawn.runtimeKind, + command: resolvedSpawn.command, + args: resolvedSpawn.args, launchMode: launchCommand.launchMode, effectiveModel: launchCommand.effectiveModel, resumeSessionId, diff --git a/src/contexts/agent/presentation/main-ipc/validate.ts b/src/contexts/agent/presentation/main-ipc/validate.ts index 372f5666..51dd2e5d 100644 --- a/src/contexts/agent/presentation/main-ipc/validate.ts +++ b/src/contexts/agent/presentation/main-ipc/validate.ts @@ -152,6 +152,7 @@ export function normalizeLaunchAgentPayload(payload: unknown): LaunchAgentInput const record = payload as Record const provider = normalizeProvider(record.provider) const cwd = typeof record.cwd === 'string' ? record.cwd.trim() : '' + const profileId = typeof record.profileId === 'string' ? record.profileId.trim() : '' const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : '' const mode = record.mode === 'resume' ? 'resume' : 'new' @@ -186,6 +187,7 @@ export function normalizeLaunchAgentPayload(payload: unknown): LaunchAgentInput return { provider, cwd, + profileId: profileId.length > 0 ? profileId : null, prompt, mode, model: model.length > 0 ? model : null, diff --git a/src/contexts/agent/presentation/renderer/hydrateAgentNode.ts b/src/contexts/agent/presentation/renderer/hydrateAgentNode.ts index 2bf09643..86726ed2 100644 --- a/src/contexts/agent/presentation/renderer/hydrateAgentNode.ts +++ b/src/contexts/agent/presentation/renderer/hydrateAgentNode.ts @@ -15,12 +15,14 @@ interface HydrateAgentNodeInput { node: Node workspacePath: string agentFullAccess: boolean + defaultTerminalProfileId?: string | null } interface FailedAgentFallbackInput { node: Node cwd: string agent: AgentNodeData + profileId?: string | null errorMessage: string } @@ -28,6 +30,7 @@ async function fallbackToFailedAgentTerminal({ node, cwd, agent, + profileId, errorMessage, }: FailedAgentFallbackInput): Promise> { const now = new Date().toISOString() @@ -35,6 +38,7 @@ async function fallbackToFailedAgentTerminal({ try { const fallback = await window.opencoveApi.pty.spawn({ cwd, + ...(profileId ? { profileId } : {}), cols: 80, rows: 24, }) @@ -103,8 +107,9 @@ async function resolvePendingResumeSessionId(node: Node): Prom export async function hydrateAgentNode({ node, - workspacePath, + workspacePath: _workspacePath, agentFullAccess, + defaultTerminalProfileId, }: HydrateAgentNodeInput): Promise> { if (node.data.kind !== 'agent' || !node.data.agent) { return node @@ -139,12 +144,14 @@ export async function hydrateAgentNode({ hasActiveAgentStatus && !isResumeSessionBindingVerified(sanitizedAgent) && sanitizedAgent.prompt.trim().length === 0 + const terminalProfileId = node.data.profileId ?? defaultTerminalProfileId ?? null if (shouldAutoResumeAgent) { try { const restoredAgent = await window.opencoveApi.agent.launch({ provider: sanitizedAgent.provider, cwd: sanitizedAgent.executionDirectory, + profileId: terminalProfileId, prompt: sanitizedAgent.prompt, mode: 'resume', model: sanitizedAgent.model, @@ -159,6 +166,8 @@ export async function hydrateAgentNode({ data: { ...node.data, sessionId: restoredAgent.sessionId, + profileId: restoredAgent.profileId, + runtimeKind: restoredAgent.runtimeKind, title: toAgentNodeTitle(sanitizedAgent.provider, restoredAgent.effectiveModel), status: restoredAgent.launchMode === 'resume' @@ -181,8 +190,9 @@ export async function hydrateAgentNode({ } catch (error) { return fallbackToFailedAgentTerminal({ node, - cwd: workspacePath, + cwd: sanitizedAgent.executionDirectory, agent: sanitizedAgent, + profileId: terminalProfileId, errorMessage: translate('messages.agentResumeFailed', { message: toErrorMessage(error) }), }) } @@ -193,6 +203,7 @@ export async function hydrateAgentNode({ const relaunchedAgent = await window.opencoveApi.agent.launch({ provider: sanitizedAgent.provider, cwd: sanitizedAgent.executionDirectory, + profileId: terminalProfileId, prompt: sanitizedAgent.prompt, mode: 'new', model: sanitizedAgent.model, @@ -206,6 +217,8 @@ export async function hydrateAgentNode({ data: { ...node.data, sessionId: relaunchedAgent.sessionId, + profileId: relaunchedAgent.profileId, + runtimeKind: relaunchedAgent.runtimeKind, title: toAgentNodeTitle(sanitizedAgent.provider, relaunchedAgent.effectiveModel), status: resolveInitialAgentRuntimeStatus(sanitizedAgent.prompt), startedAt: new Date().toISOString(), @@ -226,6 +239,7 @@ export async function hydrateAgentNode({ node, cwd: sanitizedAgent.executionDirectory, agent: sanitizedAgent, + profileId: terminalProfileId, errorMessage: translate('messages.agentLaunchFailed', { message: toErrorMessage(error) }), }) } @@ -234,6 +248,7 @@ export async function hydrateAgentNode({ try { const spawned = await window.opencoveApi.pty.spawn({ cwd: sanitizedAgent.executionDirectory, + profileId: terminalProfileId ?? undefined, cols: 80, rows: 24, }) @@ -243,6 +258,8 @@ export async function hydrateAgentNode({ data: { ...node.data, sessionId: spawned.sessionId, + profileId: spawned.profileId ?? node.data.profileId ?? defaultTerminalProfileId ?? null, + runtimeKind: spawned.runtimeKind ?? node.data.runtimeKind, status: hasActiveAgentStatus ? ('stopped' as const) : node.data.status, endedAt: hasActiveAgentStatus ? (node.data.endedAt ?? new Date().toISOString()) @@ -254,8 +271,9 @@ export async function hydrateAgentNode({ } catch (error) { return fallbackToFailedAgentTerminal({ node, - cwd: workspacePath, + cwd: sanitizedAgent.executionDirectory, agent: sanitizedAgent, + profileId: terminalProfileId, errorMessage: translate('messages.terminalLaunchFailed', { message: toErrorMessage(error) }), }) } diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentLauncher.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentLauncher.ts index 574d8276..967fbb31 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentLauncher.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentLauncher.ts @@ -83,6 +83,7 @@ export function useWorkspaceCanvasAgentLauncher({ const launched = await window.opencoveApi.agent.launch({ provider, cwd: executionDirectory, + profileId: agentSettings.defaultTerminalProfileId, prompt: '', mode: 'new', model, @@ -94,6 +95,8 @@ export function useWorkspaceCanvasAgentLauncher({ const modelLabel = launched.effectiveModel ?? model const created = await createNodeForSession({ sessionId: launched.sessionId, + profileId: launched.profileId, + runtimeKind: launched.runtimeKind, title: buildAgentNodeTitle(provider, modelLabel), anchor, kind: 'agent', diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts index 85677613..1d870867 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts @@ -19,6 +19,7 @@ interface UseAgentNodeLifecycleParams { bumpAgentLaunchToken: (nodeId: string) => number isAgentLaunchTokenCurrent: (nodeId: string, token: number) => boolean agentFullAccess: boolean + defaultTerminalProfileId: string | null } export function useWorkspaceCanvasAgentNodeLifecycle({ @@ -27,6 +28,7 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ bumpAgentLaunchToken, isAgentLaunchTokenCurrent, agentFullAccess, + defaultTerminalProfileId, }: UseAgentNodeLifecycleParams): { buildAgentNodeTitle: ( provider: AgentNodeData['provider'], @@ -146,6 +148,7 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ const launched = await window.opencoveApi.agent.launch({ provider: launchData.provider, cwd: launchData.executionDirectory, + profileId: node.data.profileId ?? defaultTerminalProfileId, prompt: launchData.prompt, mode, model: launchData.model, @@ -188,6 +191,8 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ data: { ...item.data, sessionId: launched.sessionId, + profileId: launched.profileId, + runtimeKind: launched.runtimeKind, title: buildAgentNodeTitle(launchData.provider, launched.effectiveModel), status: launched.launchMode === 'resume' @@ -234,6 +239,7 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ agentFullAccess, buildAgentNodeTitle, bumpAgentLaunchToken, + defaultTerminalProfileId, isAgentLaunchTokenCurrent, nodesRef, setNodes, diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasAgentSupport.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasAgentSupport.ts index 35f0a500..8dca9a13 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasAgentSupport.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useCanvasAgentSupport.ts @@ -53,6 +53,7 @@ export function useWorkspaceCanvasAgentSupport({ bumpAgentLaunchToken, isAgentLaunchTokenCurrent, agentFullAccess: agentSettings.agentFullAccess, + defaultTerminalProfileId: agentSettings.defaultTerminalProfileId, }) const { openAgentLauncher, openAgentLauncherForProvider } = useWorkspaceCanvasAgentLauncher({ diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.resume.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.resume.ts index 36c1ea7a..c99ff3d3 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.resume.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.resume.ts @@ -57,6 +57,7 @@ export async function resumeTaskAgentSessionAction( const launched = await window.opencoveApi.agent.launch({ provider: record.provider, cwd: record.boundDirectory, + profileId: context.agentSettings.defaultTerminalProfileId, prompt: record.prompt, mode: 'resume', model: record.model, @@ -68,6 +69,8 @@ export async function resumeTaskAgentSessionAction( const createdAgentNode = await context.createNodeForSession({ sessionId: launched.sessionId, + profileId: launched.profileId, + runtimeKind: launched.runtimeKind, title: context.buildAgentNodeTitle(record.provider, launched.effectiveModel), anchor: createTaskAgentAnchor(taskNode), kind: 'agent', diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.run.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.run.ts index e74f63e5..329c94e8 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.run.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useTaskActions.agentSession.run.ts @@ -145,6 +145,7 @@ export async function runTaskAgentAction( const launched = await window.opencoveApi.agent.launch({ provider, cwd: taskDirectory, + profileId: context.agentSettings.defaultTerminalProfileId, prompt: requirement, mode: 'new', model, @@ -155,6 +156,8 @@ export async function runTaskAgentAction( const createdAgentNode = await context.createNodeForSession({ sessionId: launched.sessionId, + profileId: launched.profileId, + runtimeKind: launched.runtimeKind, title: context.buildAgentNodeTitle(provider, launched.effectiveModel), anchor: createTaskAgentAnchor(taskNode), kind: 'agent', diff --git a/src/platform/terminal/TerminalProfileResolver.ts b/src/platform/terminal/TerminalProfileResolver.ts index 9f133b01..c3fc6fcf 100644 --- a/src/platform/terminal/TerminalProfileResolver.ts +++ b/src/platform/terminal/TerminalProfileResolver.ts @@ -1,39 +1,25 @@ import { execFile } from 'node:child_process' import os from 'node:os' -import path from 'node:path' import process from 'node:process' -import type { - ListTerminalProfilesResult, - SpawnTerminalInput, - TerminalProfile, - TerminalRuntimeKind, -} from '../../shared/contracts/dto' - -interface InternalTerminalProfile extends TerminalProfile { - resolveSpawn: (cwd: string, env: NodeJS.ProcessEnv) => ResolvedTerminalSpawn -} - -interface TerminalProfileSnapshot { - profiles: InternalTerminalProfile[] - defaultProfileId: string | null -} - -export interface TerminalProfileResolverDeps { - platform: NodeJS.Platform - env: () => NodeJS.ProcessEnv - homeDir: () => string - processCwd: () => string - locateWindowsCommands: (commands: readonly string[]) => Promise - listWslDistros: () => Promise -} - -export interface ResolvedTerminalSpawn { +import type { ListTerminalProfilesResult, SpawnTerminalInput } from '../../shared/contracts/dto' +import { + buildPowerShellExecArgs, + findProfileById, + inferLegacyRuntimeKind, + isBashLikeWindowsCommand, + isPowerShellCommand, + loadWindowsProfiles, + resolveWindowsHostCwd, + type ResolvedTerminalSpawn, + type TerminalProfileResolverDeps, +} from './TerminalProfileResolver.windows' + +export interface ResolveCommandSpawnInput { + cwd: string command: string args: string[] - cwd: string - env: NodeJS.ProcessEnv - profileId: string | null - runtimeKind: TerminalRuntimeKind + profileId?: string | null + env?: NodeJS.ProcessEnv } function execFileText(command: string, args: string[]): Promise { @@ -105,238 +91,6 @@ async function listWslDistros(): Promise { } } -function isWindowsDrivePath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) -} - -function isWslUncPath(value: string): boolean { - const normalized = value.trim().toLowerCase() - return normalized.startsWith('\\\\wsl$\\') || normalized.startsWith('\\\\wsl.localhost\\') -} - -function convertWindowsPathToWslPath(cwd: string, distro: string): string | null { - const normalized = cwd.trim() - const uncPrefix = normalized.toLowerCase().startsWith('\\\\wsl$\\') - ? '\\\\wsl$\\' - : normalized.toLowerCase().startsWith('\\\\wsl.localhost\\') - ? '\\\\wsl.localhost\\' - : null - - if (uncPrefix) { - const restPath = normalized.slice(uncPrefix.length) - const separatorIndex = restPath.indexOf('\\') - const sourceDistro = - separatorIndex >= 0 ? restPath.slice(0, separatorIndex).trim() : restPath.trim() - if (sourceDistro.localeCompare(distro, undefined, { sensitivity: 'base' }) !== 0) { - return null - } - - const rest = (separatorIndex >= 0 ? restPath.slice(separatorIndex + 1) : '') - .replace(/\\/g, '/') - .replace(/^\/+/, '') - return rest.length > 0 ? `/${rest}` : '/' - } - - const driveMatch = cwd.match(/^([A-Za-z]):(?:[\\/](.*))?$/) - if (driveMatch) { - const drive = driveMatch[1]?.toLowerCase() ?? '' - const rest = (driveMatch[2] ?? '').replace(/\\/g, '/').replace(/^\/+/, '') - return rest.length > 0 ? `/mnt/${drive}/${rest}` : `/mnt/${drive}` - } - - return null -} - -function inferLegacyRuntimeKind(shell: string, platform: NodeJS.Platform): TerminalRuntimeKind { - const normalized = shell.trim().toLowerCase() - if (normalized.endsWith('wsl.exe') || normalized === 'wsl' || normalized === 'wsl.exe') { - return 'wsl' - } - - return platform === 'win32' ? 'windows' : 'posix' -} - -function buildBashLabel(shellPath: string): string { - const normalized = shellPath.toLowerCase() - if (normalized.includes('\\git\\')) { - return 'Bash (Git Bash)' - } - - if ( - normalized.includes('\\msys') || - normalized.includes('\\mingw') || - normalized.includes('\\ucrt64') - ) { - return 'Bash (MSYS2)' - } - - if (normalized.includes('\\cygwin')) { - return 'Bash (Cygwin)' - } - - const container = path.win32.basename(path.win32.dirname(shellPath)) - return container.length > 0 ? `Bash (${container})` : 'Bash' -} - -function shouldIncludeWindowsBashProfile(shellPath: string): boolean { - const normalized = shellPath.trim().toLowerCase() - if (normalized.length === 0) { - return false - } - - return ( - !normalized.endsWith('\\windows\\system32\\bash.exe') && - !normalized.includes('\\windowsapps\\bash.exe') - ) -} - -function shouldIncludeWslDistro(distro: string): boolean { - const normalized = distro.trim().toLowerCase() - if (normalized.length === 0) { - return false - } - - return normalized !== 'docker-desktop' && normalized !== 'docker-desktop-data' -} - -function disambiguateProfileLabels(profiles: T[]): T[] { - const counts = new Map() - const labels = profiles.map(profile => { - const nextCount = (counts.get(profile.label) ?? 0) + 1 - counts.set(profile.label, nextCount) - return nextCount - }) - - return profiles.map((profile, index) => { - if ((counts.get(profile.label) ?? 0) <= 1) { - return profile - } - - return { - ...profile, - label: `${profile.label} ${labels[index]}`, - } - }) -} - -function findProfileById( - profiles: InternalTerminalProfile[], - profileId: string | null | undefined, -): InternalTerminalProfile | null { - const normalizedProfileId = typeof profileId === 'string' ? profileId.trim() : '' - if (normalizedProfileId.length === 0) { - return null - } - - return ( - profiles.find(profile => profile.id === normalizedProfileId) ?? - profiles.find( - profile => - profile.id.localeCompare(normalizedProfileId, undefined, { sensitivity: 'base' }) === 0, - ) ?? - null - ) -} - -async function loadWindowsProfiles( - deps: TerminalProfileResolverDeps, -): Promise { - const profiles: InternalTerminalProfile[] = [] - - const resolveHostCwd = (cwd: string): string => { - if (isWindowsDrivePath(cwd) || (!isWslUncPath(cwd) && path.win32.isAbsolute(cwd))) { - return cwd - } - - const homeDir = deps.homeDir().trim() - return path.win32.isAbsolute(homeDir) ? homeDir : deps.processCwd() - } - - const powershellCommands = await deps.locateWindowsCommands(['powershell.exe', 'powershell']) - if (powershellCommands.length > 0) { - const command = powershellCommands[0] ?? 'powershell.exe' - profiles.push({ - id: 'powershell', - label: 'PowerShell', - runtimeKind: 'windows', - resolveSpawn: (cwd, env) => ({ - command, - args: [], - cwd: resolveHostCwd(cwd), - env, - profileId: 'powershell', - runtimeKind: 'windows', - }), - }) - } - - const pwshCommands = await deps.locateWindowsCommands(['pwsh.exe', 'pwsh']) - if (pwshCommands.length > 0) { - const command = pwshCommands[0] ?? 'pwsh.exe' - profiles.push({ - id: 'pwsh', - label: 'PowerShell 7', - runtimeKind: 'windows', - resolveSpawn: (cwd, env) => ({ - command, - args: [], - cwd: resolveHostCwd(cwd), - env, - profileId: 'pwsh', - runtimeKind: 'windows', - }), - }) - } - - const bashCommands = (await deps.locateWindowsCommands(['bash.exe', 'bash'])).filter( - shouldIncludeWindowsBashProfile, - ) - const bashProfiles = bashCommands.map(command => ({ - id: `bash:${command.toLowerCase()}`, - label: buildBashLabel(command), - runtimeKind: 'windows', - resolveSpawn: (cwd, env) => ({ - command, - args: [], - cwd: resolveHostCwd(cwd), - env: { - ...env, - CHERE_INVOKING: '1', - }, - profileId: `bash:${command.toLowerCase()}`, - runtimeKind: 'windows', - }), - })) - profiles.push(...disambiguateProfileLabels(bashProfiles)) - - const distros = (await deps.listWslDistros()).filter(shouldIncludeWslDistro) - for (const distro of distros) { - profiles.push({ - id: `wsl:${distro}`, - label: `WSL (${distro})`, - runtimeKind: 'wsl', - resolveSpawn: (cwd, env) => { - const linuxCwd = convertWindowsPathToWslPath(cwd, distro) - return { - command: 'wsl.exe', - args: linuxCwd - ? ['--distribution', distro, '--cd', linuxCwd] - : ['--distribution', distro], - cwd: resolveHostCwd(cwd), - env, - profileId: `wsl:${distro}`, - runtimeKind: 'wsl', - } - }, - }) - } - - return { - profiles, - defaultProfileId: profiles[0]?.id ?? null, - } -} - function resolvePosixShell(shell: string | undefined): string { const normalized = typeof shell === 'string' ? shell.trim() : '' if (normalized.length > 0) { @@ -395,11 +149,7 @@ export class TerminalProfileResolver { return { command: input.shell.trim(), args: [], - cwd: - isWindowsDrivePath(input.cwd) || - (!isWslUncPath(input.cwd) && path.win32.isAbsolute(input.cwd)) - ? input.cwd - : this.deps.homeDir().trim(), + cwd: resolveWindowsHostCwd(input.cwd, this.deps.homeDir().trim(), this.deps.processCwd()), env, profileId: null, runtimeKind: inferLegacyRuntimeKind(input.shell, this.deps.platform), @@ -419,14 +169,102 @@ export class TerminalProfileResolver { return { command: 'powershell.exe', args: [], - cwd: - isWindowsDrivePath(input.cwd) || - (!isWslUncPath(input.cwd) && path.win32.isAbsolute(input.cwd)) - ? input.cwd - : this.deps.homeDir().trim(), + cwd: resolveWindowsHostCwd(input.cwd, this.deps.homeDir().trim(), this.deps.processCwd()), env, profileId: null, runtimeKind: 'windows', } } + + public async resolveCommandSpawn( + input: ResolveCommandSpawnInput, + ): Promise { + const command = input.command.trim() + const args = [...input.args] + const env = { + ...this.deps.env(), + ...(input.env ?? {}), + } + + if (this.deps.platform !== 'win32') { + return { + command, + args, + cwd: input.cwd, + env, + profileId: input.profileId?.trim() || null, + runtimeKind: 'posix', + } + } + + const snapshot = await loadWindowsProfiles(this.deps) + const selectedProfile = + findProfileById(snapshot.profiles, input.profileId) ?? + findProfileById(snapshot.profiles, snapshot.defaultProfileId) ?? + null + + if (!selectedProfile) { + return { + command, + args, + cwd: resolveWindowsHostCwd(input.cwd, this.deps.homeDir().trim(), this.deps.processCwd()), + env, + profileId: null, + runtimeKind: 'windows', + } + } + + const shellSpawn = selectedProfile.resolveSpawn(input.cwd, env) + const profileId = selectedProfile.id + + if (selectedProfile.runtimeKind === 'wsl') { + const envPairs = Object.entries(input.env ?? {}).flatMap(([key, value]) => + typeof value === 'string' ? [`${key}=${value}`] : [], + ) + return { + command: shellSpawn.command, + args: [ + ...shellSpawn.args, + ...(envPairs.length > 0 ? ['env', ...envPairs] : []), + command, + ...args, + ], + cwd: shellSpawn.cwd, + env: shellSpawn.env, + profileId, + runtimeKind: 'wsl', + } + } + + if (isBashLikeWindowsCommand(shellSpawn.command)) { + return { + command: shellSpawn.command, + args: ['--login', '-c', 'exec "$@"', 'bash', command, ...args], + cwd: shellSpawn.cwd, + env: shellSpawn.env, + profileId, + runtimeKind: selectedProfile.runtimeKind, + } + } + + if (isPowerShellCommand(shellSpawn.command)) { + return { + command: shellSpawn.command, + args: buildPowerShellExecArgs(command, args, shellSpawn.cwd), + cwd: shellSpawn.cwd, + env: shellSpawn.env, + profileId, + runtimeKind: selectedProfile.runtimeKind, + } + } + + return { + command, + args, + cwd: shellSpawn.cwd, + env: shellSpawn.env, + profileId, + runtimeKind: selectedProfile.runtimeKind, + } + } } diff --git a/src/platform/terminal/TerminalProfileResolver.windows.ts b/src/platform/terminal/TerminalProfileResolver.windows.ts new file mode 100644 index 00000000..719e4434 --- /dev/null +++ b/src/platform/terminal/TerminalProfileResolver.windows.ts @@ -0,0 +1,292 @@ +import path from 'node:path' +import type { TerminalProfile, TerminalRuntimeKind } from '../../shared/contracts/dto' + +export interface TerminalProfileResolverDeps { + platform: NodeJS.Platform + env: () => NodeJS.ProcessEnv + homeDir: () => string + processCwd: () => string + locateWindowsCommands: (commands: readonly string[]) => Promise + listWslDistros: () => Promise +} + +export interface ResolvedTerminalSpawn { + command: string + args: string[] + cwd: string + env: NodeJS.ProcessEnv + profileId: string | null + runtimeKind: TerminalRuntimeKind +} + +export interface InternalTerminalProfile extends TerminalProfile { + resolveSpawn: (cwd: string, env: NodeJS.ProcessEnv) => ResolvedTerminalSpawn +} + +export interface TerminalProfileSnapshot { + profiles: InternalTerminalProfile[] + defaultProfileId: string | null +} + +function isWindowsDrivePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) +} + +function isWslUncPath(value: string): boolean { + const normalized = value.trim().toLowerCase() + return normalized.startsWith('\\\\wsl$\\') || normalized.startsWith('\\\\wsl.localhost\\') +} + +function convertWindowsPathToWslPath(cwd: string, distro: string): string | null { + const normalized = cwd.trim() + const uncPrefix = normalized.toLowerCase().startsWith('\\\\wsl$\\') + ? '\\\\wsl$\\' + : normalized.toLowerCase().startsWith('\\\\wsl.localhost\\') + ? '\\\\wsl.localhost\\' + : null + + if (uncPrefix) { + const restPath = normalized.slice(uncPrefix.length) + const separatorIndex = restPath.indexOf('\\') + const sourceDistro = + separatorIndex >= 0 ? restPath.slice(0, separatorIndex).trim() : restPath.trim() + if (sourceDistro.localeCompare(distro, undefined, { sensitivity: 'base' }) !== 0) { + return null + } + + const rest = (separatorIndex >= 0 ? restPath.slice(separatorIndex + 1) : '') + .replace(/\\/g, '/') + .replace(/^\/+/, '') + return rest.length > 0 ? `/${rest}` : '/' + } + + const driveMatch = cwd.match(/^([A-Za-z]):(?:[\\/](.*))?$/) + if (driveMatch) { + const drive = driveMatch[1]?.toLowerCase() ?? '' + const rest = (driveMatch[2] ?? '').replace(/\\/g, '/').replace(/^\/+/, '') + return rest.length > 0 ? `/mnt/${drive}/${rest}` : `/mnt/${drive}` + } + + return null +} + +export function inferLegacyRuntimeKind( + shell: string, + platform: NodeJS.Platform, +): TerminalRuntimeKind { + const normalized = shell.trim().toLowerCase() + if (normalized.endsWith('wsl.exe') || normalized === 'wsl' || normalized === 'wsl.exe') { + return 'wsl' + } + + return platform === 'win32' ? 'windows' : 'posix' +} + +function buildBashLabel(shellPath: string): string { + const normalized = shellPath.toLowerCase() + if (normalized.includes('\\git\\')) { + return 'Bash (Git Bash)' + } + + if ( + normalized.includes('\\msys') || + normalized.includes('\\mingw') || + normalized.includes('\\ucrt64') + ) { + return 'Bash (MSYS2)' + } + + if (normalized.includes('\\cygwin')) { + return 'Bash (Cygwin)' + } + + const container = path.win32.basename(path.win32.dirname(shellPath)) + return container.length > 0 ? `Bash (${container})` : 'Bash' +} + +function shouldIncludeWindowsBashProfile(shellPath: string): boolean { + const normalized = shellPath.trim().toLowerCase() + if (normalized.length === 0) { + return false + } + + return ( + !normalized.endsWith('\\windows\\system32\\bash.exe') && + !normalized.includes('\\windowsapps\\bash.exe') + ) +} + +function shouldIncludeWslDistro(distro: string): boolean { + const normalized = distro.trim().toLowerCase() + if (normalized.length === 0) { + return false + } + + return normalized !== 'docker-desktop' && normalized !== 'docker-desktop-data' +} + +function disambiguateProfileLabels(profiles: T[]): T[] { + const counts = new Map() + const labels = profiles.map(profile => { + const nextCount = (counts.get(profile.label) ?? 0) + 1 + counts.set(profile.label, nextCount) + return nextCount + }) + + return profiles.map((profile, index) => { + if ((counts.get(profile.label) ?? 0) <= 1) { + return profile + } + + return { + ...profile, + label: `${profile.label} ${labels[index]}`, + } + }) +} + +export function findProfileById( + profiles: InternalTerminalProfile[], + profileId: string | null | undefined, +): InternalTerminalProfile | null { + const normalizedProfileId = typeof profileId === 'string' ? profileId.trim() : '' + if (normalizedProfileId.length === 0) { + return null + } + + return ( + profiles.find(profile => profile.id === normalizedProfileId) ?? + profiles.find( + profile => + profile.id.localeCompare(normalizedProfileId, undefined, { sensitivity: 'base' }) === 0, + ) ?? + null + ) +} + +export function resolveWindowsHostCwd(cwd: string, homeDir: string, processCwd: string): string { + if (isWindowsDrivePath(cwd) || (!isWslUncPath(cwd) && path.win32.isAbsolute(cwd))) { + return cwd + } + + return path.win32.isAbsolute(homeDir) ? homeDir : processCwd +} + +export async function loadWindowsProfiles( + deps: TerminalProfileResolverDeps, +): Promise { + const profiles: InternalTerminalProfile[] = [] + const resolveHostCwd = (cwd: string): string => + resolveWindowsHostCwd(cwd, deps.homeDir().trim(), deps.processCwd()) + + const powershellCommands = await deps.locateWindowsCommands(['powershell.exe', 'powershell']) + if (powershellCommands.length > 0) { + const command = powershellCommands[0] ?? 'powershell.exe' + profiles.push({ + id: 'powershell', + label: 'PowerShell', + runtimeKind: 'windows', + resolveSpawn: (cwd, env) => ({ + command, + args: [], + cwd: resolveHostCwd(cwd), + env, + profileId: 'powershell', + runtimeKind: 'windows', + }), + }) + } + + const pwshCommands = await deps.locateWindowsCommands(['pwsh.exe', 'pwsh']) + if (pwshCommands.length > 0) { + const command = pwshCommands[0] ?? 'pwsh.exe' + profiles.push({ + id: 'pwsh', + label: 'PowerShell 7', + runtimeKind: 'windows', + resolveSpawn: (cwd, env) => ({ + command, + args: [], + cwd: resolveHostCwd(cwd), + env, + profileId: 'pwsh', + runtimeKind: 'windows', + }), + }) + } + + const bashCommands = (await deps.locateWindowsCommands(['bash.exe', 'bash'])).filter( + shouldIncludeWindowsBashProfile, + ) + const bashProfiles = bashCommands.map(command => ({ + id: `bash:${command.toLowerCase()}`, + label: buildBashLabel(command), + runtimeKind: 'windows', + resolveSpawn: (cwd, env) => ({ + command, + args: [], + cwd: resolveHostCwd(cwd), + env: { + ...env, + CHERE_INVOKING: '1', + }, + profileId: `bash:${command.toLowerCase()}`, + runtimeKind: 'windows', + }), + })) + profiles.push(...disambiguateProfileLabels(bashProfiles)) + + const distros = (await deps.listWslDistros()).filter(shouldIncludeWslDistro) + for (const distro of distros) { + profiles.push({ + id: `wsl:${distro}`, + label: `WSL (${distro})`, + runtimeKind: 'wsl', + resolveSpawn: (cwd, env) => { + const linuxCwd = convertWindowsPathToWslPath(cwd, distro) + return { + command: 'wsl.exe', + args: linuxCwd + ? ['--distribution', distro, '--cd', linuxCwd] + : ['--distribution', distro], + cwd: resolveHostCwd(cwd), + env, + profileId: `wsl:${distro}`, + runtimeKind: 'wsl', + } + }, + }) + } + + return { + profiles, + defaultProfileId: profiles[0]?.id ?? null, + } +} + +export function isBashLikeWindowsCommand(command: string): boolean { + const normalized = path.win32.basename(command).trim().toLowerCase() + return normalized === 'bash.exe' || normalized === 'bash' +} + +export function isPowerShellCommand(command: string): boolean { + const normalized = path.win32.basename(command).trim().toLowerCase() + return ( + normalized === 'powershell.exe' || + normalized === 'powershell' || + normalized === 'pwsh.exe' || + normalized === 'pwsh' + ) +} + +function quotePowerShellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +export function buildPowerShellExecArgs(command: string, args: string[], cwd: string): string[] { + const invocation = [quotePowerShellLiteral(command), ...args.map(quotePowerShellLiteral)].join( + ' ', + ) + const script = `Set-Location -LiteralPath ${quotePowerShellLiteral(cwd)}; & ${invocation}; exit $LASTEXITCODE` + return ['-NoLogo', '-Command', script] +} diff --git a/src/shared/contracts/dto/agent.ts b/src/shared/contracts/dto/agent.ts index 2ef16bde..2230a397 100644 --- a/src/shared/contracts/dto/agent.ts +++ b/src/shared/contracts/dto/agent.ts @@ -2,6 +2,7 @@ export type AgentProviderId = 'claude-code' | 'codex' | 'opencode' | 'gemini' export type AgentModelCatalogSource = 'claude-static' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' import type { AppErrorDescriptor } from './error' +import type { TerminalRuntimeKind } from './terminal' export type AgentLaunchMode = 'new' | 'resume' @@ -31,6 +32,7 @@ export interface ListAgentModelsResult { export interface LaunchAgentInput { provider: AgentProviderId cwd: string + profileId?: string | null prompt: string mode?: AgentLaunchMode model?: string | null @@ -43,6 +45,8 @@ export interface LaunchAgentInput { export interface LaunchAgentResult { sessionId: string provider: AgentProviderId + profileId?: string | null + runtimeKind?: TerminalRuntimeKind command: string args: string[] launchMode: AgentLaunchMode diff --git a/tests/e2e/workspace-canvas.terminal-wheel.spec.ts b/tests/e2e/workspace-canvas.terminal-wheel.spec.ts index 349fa00e..23ea691b 100644 --- a/tests/e2e/workspace-canvas.terminal-wheel.spec.ts +++ b/tests/e2e/workspace-canvas.terminal-wheel.spec.ts @@ -3,6 +3,7 @@ import { buildEchoSequenceCommand, clearAndSeedWorkspace, launchApp, + testWorkspacePath, } from './workspace-canvas.helpers' test.describe('Workspace Canvas - Terminal Wheel', () => { @@ -79,4 +80,67 @@ test.describe('Workspace Canvas - Terminal Wheel', () => { await electronApp.close() } }) + + test('wheel over a hydrated agent node scrolls the viewport instead of the canvas', async () => { + const { electronApp, window } = await launchApp() + + try { + await clearAndSeedWorkspace(window, [ + { + id: 'node-agent-scroll', + title: 'codex · gpt-5.2-codex', + position: { x: 120, y: 120 }, + width: 460, + height: 300, + kind: 'agent', + status: 'running', + startedAt: '2026-02-09T00:00:00.000Z', + endedAt: null, + exitCode: null, + lastError: null, + agent: { + provider: 'codex', + prompt: 'hydrate into fallback shell and keep scroll working', + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new', + resumeSessionId: null, + resumeSessionIdVerified: false, + executionDirectory: testWorkspacePath, + expectedDirectory: testWorkspacePath, + directoryMode: 'workspace', + customDirectory: null, + shouldCreateDirectory: false, + }, + }, + ]) + + const agentNode = window.locator('.terminal-node').first() + await expect(agentNode).toBeVisible() + const xterm = agentNode.locator('.xterm') + await expect(xterm).toBeVisible() + await xterm.click() + const terminalInput = agentNode.locator('.xterm-helper-textarea') + await expect(terminalInput).toBeFocused() + await window.keyboard.type(buildEchoSequenceCommand('OPENCOVE_AGENT_SCROLL', 260)) + await window.keyboard.press('Enter') + await expect(agentNode).toContainText('OPENCOVE_AGENT_SCROLL_260') + + const viewport = window.locator('.react-flow__viewport') + const beforeTransform = await viewport.getAttribute('style') + const visibleRows = agentNode.locator('.xterm-rows') + const beforeRows = await visibleRows.innerText() + + await agentNode.hover() + await window.mouse.wheel(0, -1200) + await window.waitForTimeout(120) + + const afterRows = await visibleRows.innerText() + const afterTransform = await viewport.getAttribute('style') + expect(afterRows).not.toBe(beforeRows) + expect(afterTransform).toBe(beforeTransform) + } finally { + await electronApp.close() + } + }) }) diff --git a/tests/integration/recovery/useHydrateAppState.agentSession.spec.tsx b/tests/integration/recovery/useHydrateAppState.agentSession.spec.tsx index 3fd5de3e..ce19f376 100644 --- a/tests/integration/recovery/useHydrateAppState.agentSession.spec.tsx +++ b/tests/integration/recovery/useHydrateAppState.agentSession.spec.tsx @@ -9,10 +9,12 @@ function createPersistedState({ prompt, status, startedAt, + profileId = null, }: { prompt: string status: 'running' | 'standby' startedAt: string + profileId?: string | null }) { return { activeWorkspaceId: 'workspace-1', @@ -39,6 +41,7 @@ function createPersistedState({ exitCode: null, lastError: null, scrollback: null, + profileId, agent: { provider: 'codex', prompt, @@ -135,6 +138,7 @@ describe('useHydrateAppState agent session restore', () => { prompt: '', status: 'standby', startedAt: originalStartedAt, + profileId: 'wsl:Ubuntu', }), ), ) @@ -170,6 +174,7 @@ describe('useHydrateAppState agent session restore', () => { expect(launch).toHaveBeenCalledWith({ provider: 'codex', cwd: '/tmp/workspace-1/agent', + profileId: 'wsl:Ubuntu', prompt: '', mode: 'new', model: 'gpt-5.2-codex', @@ -286,6 +291,7 @@ describe('useHydrateAppState agent session restore', () => { expect(launch).toHaveBeenCalledWith({ provider: 'codex', cwd: '/tmp/workspace-1/agent', + profileId: null, prompt: 'implement login flow', mode: 'resume', model: 'gpt-5.2-codex', @@ -302,4 +308,64 @@ describe('useHydrateAppState agent session restore', () => { ) expect(screen.getByTestId('agent-resume-session-verified')).toHaveTextContent('true') }) + + it('falls back to the agent execution directory when resume restore fails', async () => { + const storage = installMockStorage() + + storage.setItem( + 'opencove:m0:workspace-state', + JSON.stringify( + createPersistedState({ + prompt: 'recover login flow', + status: 'running', + startedAt: '2026-03-08T09:00:00.000Z', + profileId: 'wsl:Ubuntu', + }), + ), + ) + + const spawn = vi.fn(async () => ({ + sessionId: 'fallback-shell-session', + profileId: 'wsl:Ubuntu', + runtimeKind: 'wsl' as const, + })) + const launch = vi.fn(async () => { + throw new Error('resume failed') + }) + const resolveResumeSessionId = vi.fn(async () => ({ + resumeSessionId: 'resolved-codex-session', + })) + + installMockApi({ spawn, launch, resolveResumeSessionId }) + + const { useHydrateAppState } = + await import('../../../src/app/renderer/shell/hooks/useHydrateAppState') + + render(React.createElement(createHarness(useHydrateAppState))) + + await waitFor(() => { + expect(screen.getByTestId('hydrated')).toHaveTextContent('true') + }) + + expect(launch).toHaveBeenCalledWith({ + provider: 'codex', + cwd: '/tmp/workspace-1/agent', + profileId: 'wsl:Ubuntu', + prompt: 'recover login flow', + mode: 'resume', + model: 'gpt-5.2-codex', + resumeSessionId: 'resolved-codex-session', + agentFullAccess: true, + cols: 80, + rows: 24, + }) + expect(spawn).toHaveBeenCalledWith({ + cwd: '/tmp/workspace-1/agent', + profileId: 'wsl:Ubuntu', + cols: 80, + rows: 24, + }) + expect(screen.getByTestId('agent-session-id')).toHaveTextContent('fallback-shell-session') + expect(screen.getByTestId('agent-status')).toHaveTextContent('failed') + }) }) diff --git a/tests/unit/contexts/controlSurface.sessionHandlers.spec.ts b/tests/unit/contexts/controlSurface.sessionHandlers.spec.ts index 0dc453df..bdf121b6 100644 --- a/tests/unit/contexts/controlSurface.sessionHandlers.spec.ts +++ b/tests/unit/contexts/controlSurface.sessionHandlers.spec.ts @@ -114,6 +114,10 @@ describe('control surface session handlers', () => { } let killed: string | null = null + let spawnedCommand: { + command: string + args: string[] + } | null = null const controlSurface = createControlSurface() registerSessionHandlers(controlSurface, { @@ -123,7 +127,13 @@ describe('control surface session handlers', () => { }, getPersistenceStore: async () => createStubStore(appState), ptyRuntime: { - spawnSession: async () => ({ sessionId: 'pty-123' }), + spawnSession: async input => { + spawnedCommand = { + command: input.command, + args: input.args, + } + return { sessionId: 'pty-123' } + }, write: () => undefined, resize: () => undefined, kill: sessionId => { @@ -163,8 +173,8 @@ describe('control surface session handlers', () => { expect(fetched.value.cwd).toBe('/repo') expect(fetched.value.provider).toBe('codex') expect('startedAtMs' in fetched.value).toBe(false) - expect(fetched.value.command).toBe('codex') - expect(Array.isArray(fetched.value.args)).toBe(true) + expect(fetched.value.command).toBe(spawnedCommand?.command) + expect(fetched.value.args).toEqual(spawnedCommand?.args) } const killedResult = await controlSurface.invoke(ctx, { diff --git a/tests/unit/contexts/terminalProfileResolver.spec.ts b/tests/unit/contexts/terminalProfileResolver.spec.ts index de4eec3f..75b51119 100644 --- a/tests/unit/contexts/terminalProfileResolver.spec.ts +++ b/tests/unit/contexts/terminalProfileResolver.spec.ts @@ -171,4 +171,76 @@ describe('TerminalProfileResolver', () => { runtimeKind: 'wsl', }) }) + + it('resolves agent commands through the selected WSL profile', async () => { + const resolver = new TerminalProfileResolver({ + platform: 'win32', + env: () => ({ PATH: 'C:\\Windows\\System32' }), + homeDir: () => 'C:\\Users\\tester', + locateWindowsCommands: async () => [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ], + listWslDistros: async () => ['Ubuntu'], + }) + + const result = await resolver.resolveCommandSpawn({ + cwd: 'C:\\repo', + profileId: 'wsl:Ubuntu', + command: 'codex', + args: ['resume', 'session-1'], + env: { + OPENCOVE_OPENCODE_SERVER_PORT: '5173', + }, + }) + + expect(result).toMatchObject({ + command: 'wsl.exe', + args: [ + '--distribution', + 'Ubuntu', + '--cd', + '/mnt/c/repo', + 'env', + 'OPENCOVE_OPENCODE_SERVER_PORT=5173', + 'codex', + 'resume', + 'session-1', + ], + cwd: 'C:\\repo', + profileId: 'wsl:Ubuntu', + runtimeKind: 'wsl', + }) + }) + + it('resolves agent commands through Git Bash with login shell exec semantics', async () => { + const resolver = new TerminalProfileResolver({ + platform: 'win32', + env: () => ({ PATH: 'C:\\Windows\\System32', FOO: 'bar' }), + locateWindowsCommands: async commands => { + if (commands.includes('bash.exe')) { + return ['D:\\Git\\bin\\bash.exe'] + } + + return ['C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'] + }, + listWslDistros: async () => [], + }) + + const result = await resolver.resolveCommandSpawn({ + cwd: 'D:\\repo', + profileId: 'bash:d:\\git\\bin\\bash.exe', + command: 'codex', + args: ['resume', 'session-1'], + }) + + expect(result).toMatchObject({ + command: 'D:\\Git\\bin\\bash.exe', + args: ['--login', '-c', 'exec "$@"', 'bash', 'codex', 'resume', 'session-1'], + cwd: 'D:\\repo', + profileId: 'bash:d:\\git\\bin\\bash.exe', + runtimeKind: 'windows', + }) + expect(result.env.CHERE_INVOKING).toBe('1') + expect(result.env.FOO).toBe('bar') + }) }) diff --git a/tests/unit/contexts/workspaceCanvas.runDefaultAgent.spec.tsx b/tests/unit/contexts/workspaceCanvas.runDefaultAgent.spec.tsx index d5ee64a4..91888053 100644 --- a/tests/unit/contexts/workspaceCanvas.runDefaultAgent.spec.tsx +++ b/tests/unit/contexts/workspaceCanvas.runDefaultAgent.spec.tsx @@ -164,6 +164,7 @@ describe('WorkspaceCanvas run default agent', () => { agentSettings={{ ...DEFAULT_AGENT_SETTINGS, defaultProvider: 'codex', + defaultTerminalProfileId: 'wsl:Ubuntu', defaultTerminalWindowScalePercent: 120, customModelEnabledByProvider: { ...DEFAULT_AGENT_SETTINGS.customModelEnabledByProvider, @@ -200,6 +201,7 @@ describe('WorkspaceCanvas run default agent', () => { expect.objectContaining({ provider: 'codex', cwd: '/tmp/repo', + profileId: 'wsl:Ubuntu', prompt: '', mode: 'new', model: 'gpt-5.2-codex', @@ -327,6 +329,7 @@ describe('WorkspaceCanvas run default agent', () => { expect.objectContaining({ provider: 'claude-code', cwd: '/tmp/repo', + profileId: null, prompt: '', mode: 'new', model: 'claude-sonnet-4-6', From d5346cc22325c73c56fc3e13cb894b4909b9bc8e Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 20:51:26 +0800 Subject: [PATCH 02/22] Update changelog for agent terminal recovery --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3049789a..cbdb69a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Spaces: warn before closing the last node in a space when it would become empty and auto-close, using the shared warning dialog shell. (#66) ### 🐞 Fixed +- Agent windows now inherit terminal profile runtime/env semantics during launch, recovery, and fallback; Windows raw-TUI wheel handling is covered by regression tests. (#110) - Persistence: Repair cumulative SQLite schema upgrades and auto-heal mis-versioned local databases so workspace state saves no longer fail after upgrading from older installs. (#76) - Spaces: New windows created from a crowded space now preserve existing window layout, expand the space only as needed, and keep the viewport centered on the final position. (#62) - Settings: update status now summarizes updater feed parsing errors instead of dumping raw parser/CSP output. (#67) From 78a04eb2c178ec829ebe0a4b5c7a5860b3c74f60 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 20:58:36 +0800 Subject: [PATCH 03/22] Hide duplicate agent cursor in alt screen --- src/app/renderer/styles/terminal-node.css | 4 + .../renderer/components/TerminalNode.tsx | 8 ++ .../terminalNode/agentCursorVisibility.ts | 39 +++++++++ .../terminalNode.hydration-buffer.spec.tsx | 83 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts diff --git a/src/app/renderer/styles/terminal-node.css b/src/app/renderer/styles/terminal-node.css index f9c12bd4..80a1426c 100644 --- a/src/app/renderer/styles/terminal-node.css +++ b/src/app/renderer/styles/terminal-node.css @@ -162,6 +162,10 @@ overscroll-behavior: contain; } +.terminal-node__terminal[data-cove-agent-alt-buffer='true'] .xterm-cursor-layer .xterm-cursor { + opacity: 0; +} + .terminal-node__resizer { position: absolute; background: transparent; diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index 2b0976ff..d79fbe8b 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -25,6 +25,7 @@ import { resolveTerminalNodeFrameStyle } from './terminalNode/nodeFrameStyle' import { resolveTerminalTheme, resolveTerminalUiTheme } from './terminalNode/theme' import { registerTerminalSelectionTestHandle } from './terminalNode/testHarness' import { patchXtermMouseServiceWithRetry } from './terminalNode/patchXtermMouseService' +import { bindAgentCursorVisibility } from './terminalNode/agentCursorVisibility' import { useTerminalThemeApplier } from './terminalNode/useTerminalThemeApplier' import { useTerminalBodyClickFallback } from './terminalNode/useTerminalBodyClickFallback' import { useTerminalFind } from './terminalNode/useTerminalFind' @@ -201,9 +202,15 @@ export function TerminalNode({ }), ) let cancelMouseServicePatch: () => void = () => undefined + let disposeAgentCursorVisibility: () => void = () => undefined if (containerRef.current) { terminal.open(containerRef.current) containerRef.current.setAttribute('data-cove-terminal-theme', resolvedTerminalUiTheme) + disposeAgentCursorVisibility = bindAgentCursorVisibility({ + terminal, + container: containerRef.current, + isAgentNode: kind === 'agent', + }) cancelMouseServicePatch = patchXtermMouseServiceWithRetry(terminal) if (window.opencoveApi.meta.isTest) { disposeTerminalSelectionTestHandle = registerTerminalSelectionTestHandle(nodeId, terminal) @@ -397,6 +404,7 @@ export function TerminalNode({ } cancelMouseServicePatch() + disposeAgentCursorVisibility() isDisposed = true const detachPromise = ptyWithOptionalAttach.detach?.({ sessionId }) void detachPromise?.catch(() => undefined) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts new file mode 100644 index 00000000..295585cc --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts @@ -0,0 +1,39 @@ +import type { Terminal } from '@xterm/xterm' + +function syncAgentAltBufferMarker( + container: HTMLElement, + isAgentNode: boolean, + bufferType: 'normal' | 'alternate', +): void { + if (!isAgentNode || bufferType !== 'alternate') { + container.removeAttribute('data-cove-agent-alt-buffer') + return + } + + container.setAttribute('data-cove-agent-alt-buffer', 'true') +} + +export function bindAgentCursorVisibility({ + terminal, + container, + isAgentNode, +}: { + terminal: Terminal + container: HTMLElement + isAgentNode: boolean +}): () => void { + syncAgentAltBufferMarker(container, isAgentNode, terminal.buffer.active.type) + + if (!isAgentNode) { + return () => undefined + } + + const disposable = terminal.buffer.onBufferChange(buffer => { + syncAgentAltBufferMarker(container, true, buffer.type) + }) + + return () => { + disposable.dispose() + container.removeAttribute('data-cove-agent-alt-buffer') + } +} diff --git a/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx b/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx index 210eba4c..e4845b14 100644 --- a/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx +++ b/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx @@ -36,8 +36,20 @@ vi.mock('@xterm/xterm', () => { public options: { fontSize: number; theme?: unknown } = { fontSize: 13 } public written: string[] = [] public refreshCalls = 0 + public buffer = { + active: { type: 'normal' as 'normal' | 'alternate' }, + onBufferChange: (listener: (buffer: { type: 'normal' | 'alternate' }) => void) => { + this.bufferChangeListener = listener + return { + dispose: () => { + this.bufferChangeListener = null + }, + } + }, + } private dataListener: ((data: string) => void) | null = null private binaryListener: ((data: string) => void) | null = null + private bufferChangeListener: ((buffer: { type: 'normal' | 'alternate' }) => void) | null = null public constructor(options?: { cols?: number; rows?: number; theme?: unknown }) { MockTerminal.lastInstance = this @@ -88,6 +100,11 @@ vi.mock('@xterm/xterm', () => { callback?.() } + public emitBufferChange(type: 'normal' | 'alternate'): void { + this.buffer.active = { type } + this.bufferChangeListener?.({ type }) + } + public emitBinary(data: string): void { this.binaryListener?.(data) } @@ -386,4 +403,70 @@ describe('TerminalNode hydration buffering', () => { }) }) }) + + it('marks alternate-screen agent nodes so the native xterm cursor can be hidden', async () => { + if (typeof window.ResizeObserver === 'undefined') { + window.ResizeObserver = class ResizeObserver { + public observe(): void {} + public disconnect(): void {} + public unobserve(): void {} + } + } + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + meta: { + isTest: true, + }, + pty: { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + snapshot: vi.fn(async () => ({ data: '' })), + onData: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }, + }) + + const { TerminalNode } = + await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') + + const { container } = render( + undefined} + onResize={() => undefined} + />, + ) + + const terminalContainer = container.querySelector('.terminal-node__terminal') + expect(terminalContainer).not.toHaveAttribute('data-cove-agent-alt-buffer') + + const { __getLastTerminal } = await import('@xterm/xterm') + __getLastTerminal()?.emitBufferChange('alternate') + + await waitFor(() => { + expect(terminalContainer).toHaveAttribute('data-cove-agent-alt-buffer', 'true') + }) + + __getLastTerminal()?.emitBufferChange('normal') + + await waitFor(() => { + expect(terminalContainer).not.toHaveAttribute('data-cove-agent-alt-buffer') + }) + }) }) From 2eb24094f1c90d16380c424d3d001fb73942b347 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 21:12:22 +0800 Subject: [PATCH 04/22] Reduce agent terminal layout sync churn --- src/app/renderer/styles/terminal-node.css | 4 - .../renderer/components/TerminalNode.tsx | 8 - .../hooks/useAgentNodeLifecycle.ts | 252 +++++++++--------- .../hooks/usePtyTaskCompletion.ts | 154 ++++++----- tests/unit/contexts/agentLayoutSync.spec.tsx | 175 ++++++++++++ .../terminalNode.hydration-buffer.spec.tsx | 83 ------ 6 files changed, 388 insertions(+), 288 deletions(-) create mode 100644 tests/unit/contexts/agentLayoutSync.spec.tsx diff --git a/src/app/renderer/styles/terminal-node.css b/src/app/renderer/styles/terminal-node.css index 80a1426c..f9c12bd4 100644 --- a/src/app/renderer/styles/terminal-node.css +++ b/src/app/renderer/styles/terminal-node.css @@ -162,10 +162,6 @@ overscroll-behavior: contain; } -.terminal-node__terminal[data-cove-agent-alt-buffer='true'] .xterm-cursor-layer .xterm-cursor { - opacity: 0; -} - .terminal-node__resizer { position: absolute; background: transparent; diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index d79fbe8b..2b0976ff 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -25,7 +25,6 @@ import { resolveTerminalNodeFrameStyle } from './terminalNode/nodeFrameStyle' import { resolveTerminalTheme, resolveTerminalUiTheme } from './terminalNode/theme' import { registerTerminalSelectionTestHandle } from './terminalNode/testHarness' import { patchXtermMouseServiceWithRetry } from './terminalNode/patchXtermMouseService' -import { bindAgentCursorVisibility } from './terminalNode/agentCursorVisibility' import { useTerminalThemeApplier } from './terminalNode/useTerminalThemeApplier' import { useTerminalBodyClickFallback } from './terminalNode/useTerminalBodyClickFallback' import { useTerminalFind } from './terminalNode/useTerminalFind' @@ -202,15 +201,9 @@ export function TerminalNode({ }), ) let cancelMouseServicePatch: () => void = () => undefined - let disposeAgentCursorVisibility: () => void = () => undefined if (containerRef.current) { terminal.open(containerRef.current) containerRef.current.setAttribute('data-cove-terminal-theme', resolvedTerminalUiTheme) - disposeAgentCursorVisibility = bindAgentCursorVisibility({ - terminal, - container: containerRef.current, - isAgentNode: kind === 'agent', - }) cancelMouseServicePatch = patchXtermMouseServiceWithRetry(terminal) if (window.opencoveApi.meta.isTest) { disposeTerminalSelectionTestHandle = registerTerminalSelectionTestHandle(nodeId, terminal) @@ -404,7 +397,6 @@ export function TerminalNode({ } cancelMouseServicePatch() - disposeAgentCursorVisibility() isDisposed = true const detachPromise = ptyWithOptionalAttach.detach?.({ sessionId }) void detachPromise?.catch(() => undefined) diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts index 1d870867..62a116e5 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle.ts @@ -55,41 +55,45 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ const launchData = node.data.agent if (mode === 'resume' && !isResumeSessionBindingVerified(launchData)) { - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - return { - ...item, - data: { - ...item.data, - status: 'failed', - lastError: t('messages.resumeSessionMissing'), - }, - } - }), + return { + ...item, + data: { + ...item.data, + status: 'failed', + lastError: t('messages.resumeSessionMissing'), + }, + } + }), + { syncLayout: false }, ) return } if (mode === 'new' && launchData.prompt.trim().length === 0) { - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - return { - ...item, - data: { - ...item.data, - status: 'failed', - lastError: t('messages.agentPromptRequired'), - }, - } - }), + return { + ...item, + data: { + ...item.data, + status: 'failed', + lastError: t('messages.agentPromptRequired'), + }, + } + }), + { syncLayout: false }, ) return } @@ -117,31 +121,33 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ return } - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - return { - ...item, - data: { - ...item.data, - status: 'restoring', - endedAt: null, - exitCode: null, - lastError: null, - agent: - mode === 'new' && item.data.agent - ? { - ...item.data.agent, - launchMode: 'new', - ...clearResumeSessionBinding(), - } - : item.data.agent, - }, - } - }), + return { + ...item, + data: { + ...item.data, + status: 'restoring', + endedAt: null, + exitCode: null, + lastError: null, + agent: + mode === 'new' && item.data.agent + ? { + ...item.data.agent, + launchMode: 'new', + ...clearResumeSessionBinding(), + } + : item.data.agent, + }, + } + }), + { syncLayout: false }, ) try { @@ -168,46 +174,48 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ return } - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - const nextAgentData: AgentNodeData = { - ...launchData, - launchMode: launched.launchMode, - effectiveModel: launched.effectiveModel, - ...(mode === 'resume' - ? { - resumeSessionId: launched.resumeSessionId ?? launchData.resumeSessionId, - resumeSessionIdVerified: true, - } - : clearResumeSessionBinding()), - } + const nextAgentData: AgentNodeData = { + ...launchData, + launchMode: launched.launchMode, + effectiveModel: launched.effectiveModel, + ...(mode === 'resume' + ? { + resumeSessionId: launched.resumeSessionId ?? launchData.resumeSessionId, + resumeSessionIdVerified: true, + } + : clearResumeSessionBinding()), + } - return { - ...item, - data: { - ...item.data, - sessionId: launched.sessionId, - profileId: launched.profileId, - runtimeKind: launched.runtimeKind, - title: buildAgentNodeTitle(launchData.provider, launched.effectiveModel), - status: - launched.launchMode === 'resume' - ? ('standby' as const) - : resolveInitialAgentRuntimeStatus(launchData.prompt), - startedAt: - mode === 'new' ? new Date().toISOString() : (item.data.startedAt ?? null), - endedAt: null, - exitCode: null, - lastError: null, - scrollback: mode === 'new' ? null : item.data.scrollback, - agent: nextAgentData, - }, - } - }), + return { + ...item, + data: { + ...item.data, + sessionId: launched.sessionId, + profileId: launched.profileId, + runtimeKind: launched.runtimeKind, + title: buildAgentNodeTitle(launchData.provider, launched.effectiveModel), + status: + launched.launchMode === 'resume' + ? ('standby' as const) + : resolveInitialAgentRuntimeStatus(launchData.prompt), + startedAt: + mode === 'new' ? new Date().toISOString() : (item.data.startedAt ?? null), + endedAt: null, + exitCode: null, + lastError: null, + scrollback: mode === 'new' ? null : item.data.scrollback, + agent: nextAgentData, + }, + } + }), + { syncLayout: false }, ) } catch (error) { if (!isAgentLaunchTokenCurrent(nodeId, launchToken)) { @@ -216,22 +224,24 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ const errorMessage = t('messages.agentLaunchFailed', { message: toErrorMessage(error) }) - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - return { - ...item, - data: { - ...item.data, - status: 'failed', - endedAt: new Date().toISOString(), - lastError: errorMessage, - }, - } - }), + return { + ...item, + data: { + ...item.data, + status: 'failed', + endedAt: new Date().toISOString(), + lastError: errorMessage, + }, + } + }), + { syncLayout: false }, ) } }, @@ -261,22 +271,24 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ await window.opencoveApi.pty.kill({ sessionId: node.data.sessionId }) } - setNodes(prevNodes => - prevNodes.map(item => { - if (item.id !== nodeId) { - return item - } + setNodes( + prevNodes => + prevNodes.map(item => { + if (item.id !== nodeId) { + return item + } - return { - ...item, - data: { - ...item.data, - status: 'stopped', - endedAt: new Date().toISOString(), - exitCode: null, - }, - } - }), + return { + ...item, + data: { + ...item.data, + status: 'stopped', + endedAt: new Date().toISOString(), + exitCode: null, + }, + } + }), + { syncLayout: false }, ) }, [bumpAgentLaunchToken, nodesRef, setNodes], diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts index 2c907d7d..cb2f5f70 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion.ts @@ -51,82 +51,87 @@ export function useWorkspaceCanvasPtyTaskCompletion({ const ptyEventHub = getPtyEventHub() const unsubscribeState = ptyEventHub.onState(event => { - setNodes(prevNodes => - prevNodes.map(node => { - if (node.data.kind !== 'agent' || node.data.sessionId !== event.sessionId) { - return node - } - - if ( - node.data.status === 'failed' || - node.data.status === 'stopped' || - node.data.status === 'exited' - ) { - return node - } - - const nextStatus = event.state === 'standby' ? 'standby' : 'running' - if (node.data.status === nextStatus) { - return node - } - - return { - ...node, - data: { - ...node.data, - status: nextStatus, - }, - } - }), + setNodes( + prevNodes => + prevNodes.map(node => { + if (node.data.kind !== 'agent' || node.data.sessionId !== event.sessionId) { + return node + } + + if ( + node.data.status === 'failed' || + node.data.status === 'stopped' || + node.data.status === 'exited' + ) { + return node + } + + const nextStatus = event.state === 'standby' ? 'standby' : 'running' + if (node.data.status === nextStatus) { + return node + } + + return { + ...node, + data: { + ...node.data, + status: nextStatus, + }, + } + }), + { syncLayout: false }, ) }) const unsubscribeMetadata = ptyEventHub.onMetadata(event => { let didChange = false - setNodes(prevNodes => { - const nextNodes = prevNodes.map(node => { - if ( - node.data.kind !== 'agent' || - node.data.sessionId !== event.sessionId || - !node.data.agent - ) { - return node - } - - const nextResumeSessionId = - typeof event.resumeSessionId === 'string' && event.resumeSessionId.trim().length > 0 - ? event.resumeSessionId - : null - const nextResumeSessionIdVerified = nextResumeSessionId !== null - - if ( - node.data.agent.resumeSessionId === nextResumeSessionId && - node.data.agent.resumeSessionIdVerified === nextResumeSessionIdVerified - ) { - return node - } - - if (nextResumeSessionId === null) { - return node - } - - didChange = true - return { - ...node, - data: { - ...node.data, - agent: { - ...node.data.agent, - resumeSessionId: nextResumeSessionId, - resumeSessionIdVerified: true, + setNodes( + prevNodes => { + const nextNodes = prevNodes.map(node => { + if ( + node.data.kind !== 'agent' || + node.data.sessionId !== event.sessionId || + !node.data.agent + ) { + return node + } + + const nextResumeSessionId = + typeof event.resumeSessionId === 'string' && event.resumeSessionId.trim().length > 0 + ? event.resumeSessionId + : null + const nextResumeSessionIdVerified = nextResumeSessionId !== null + + if ( + node.data.agent.resumeSessionId === nextResumeSessionId && + node.data.agent.resumeSessionIdVerified === nextResumeSessionIdVerified + ) { + return node + } + + if (nextResumeSessionId === null) { + return node + } + + didChange = true + return { + ...node, + data: { + ...node.data, + agent: { + ...node.data.agent, + resumeSessionId: nextResumeSessionId, + resumeSessionIdVerified: true, + }, }, - }, - } - }) + } + }) - return didChange ? nextNodes : prevNodes - }) + return didChange ? nextNodes : prevNodes + }, + { syncLayout: false }, + ) if (didChange) { onRequestPersistFlush?.() @@ -136,11 +141,14 @@ export function useWorkspaceCanvasPtyTaskCompletion({ const unsubscribeExit = ptyEventHub.onExit(event => { let didChange = false - setNodes(prevNodes => { - const result = applyAgentExitToNodes(prevNodes, event) - didChange = result.didChange - return result.nextNodes - }) + setNodes( + prevNodes => { + const result = applyAgentExitToNodes(prevNodes, event) + didChange = result.didChange + return result.nextNodes + }, + { syncLayout: false }, + ) if (didChange) { onRequestPersistFlush?.() diff --git a/tests/unit/contexts/agentLayoutSync.spec.tsx b/tests/unit/contexts/agentLayoutSync.spec.tsx new file mode 100644 index 00000000..38ffc776 --- /dev/null +++ b/tests/unit/contexts/agentLayoutSync.spec.tsx @@ -0,0 +1,175 @@ +import React, { useEffect } from 'react' +import { render, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { Node } from '@xyflow/react' +import type { TerminalNodeData } from '../../../src/contexts/workspace/presentation/renderer/types' +import { useWorkspaceCanvasAgentNodeLifecycle } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useAgentNodeLifecycle' +import { useWorkspaceCanvasPtyTaskCompletion } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/usePtyTaskCompletion' + +vi.mock('@app/renderer/i18n', () => { + return { + useTranslation: () => ({ + t: (key: string, params?: { message?: string }) => + params?.message ? `${key}: ${params.message}` : key, + }), + } +}) + +function createAgentNode(): Node { + return { + id: 'agent-1', + type: 'terminalNode', + position: { x: 0, y: 0 }, + data: { + sessionId: '', + profileId: 'wsl:Ubuntu', + runtimeKind: 'wsl', + title: 'codex · default', + width: 520, + height: 360, + kind: 'agent', + status: 'standby', + startedAt: new Date().toISOString(), + endedAt: null, + exitCode: null, + lastError: null, + scrollback: null, + agent: { + provider: 'codex', + prompt: 'ship it', + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new', + resumeSessionId: null, + executionDirectory: '/tmp/project', + expectedDirectory: '/tmp/project', + directoryMode: 'workspace', + customDirectory: null, + shouldCreateDirectory: false, + taskId: null, + }, + task: null, + note: null, + image: null, + }, + draggable: true, + selectable: true, + } +} + +describe('agent terminal layout sync', () => { + it('does not trigger layout sync during agent lifecycle status updates', async () => { + const nodesRef = { + current: [createAgentNode()], + } as React.MutableRefObject[]> + + const setNodes = vi.fn( + ( + updater: (prevNodes: Node[]) => Node[], + _options?: { syncLayout?: boolean }, + ) => { + nodesRef.current = updater(nodesRef.current) + }, + ) + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + agent: { + launch: vi.fn(async () => ({ + sessionId: 'agent-session-1', + profileId: 'wsl:Ubuntu', + runtimeKind: 'wsl', + command: 'wsl.exe', + args: ['--distribution', 'Ubuntu'], + launchMode: 'new', + effectiveModel: 'gpt-5.2-codex', + resumeSessionId: null, + })), + }, + pty: { + kill: vi.fn(async () => undefined), + }, + workspace: { + ensureDirectory: vi.fn(async () => undefined), + }, + }, + }) + + function Harness(): null { + const { launchAgentInNode } = useWorkspaceCanvasAgentNodeLifecycle({ + nodesRef, + setNodes, + bumpAgentLaunchToken: () => 1, + isAgentLaunchTokenCurrent: () => true, + agentFullAccess: true, + defaultTerminalProfileId: 'wsl:Ubuntu', + }) + + useEffect(() => { + void launchAgentInNode('agent-1', 'new') + }, [launchAgentInNode]) + + return null + } + + render() + + await waitFor(() => { + expect(window.opencoveApi.agent.launch).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => { + expect(setNodes).toHaveBeenCalledTimes(2) + }) + + expect(setNodes.mock.calls.map(call => call[1])).toEqual([ + { syncLayout: false }, + { syncLayout: false }, + ]) + }) + + it('does not trigger layout sync for agent runtime state events', async () => { + let onStateListener: + | ((event: { sessionId: string; state: 'running' | 'standby' }) => void) + | null = null + + const setNodes = vi.fn() + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + pty: { + onData: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + onState: vi.fn((listener: typeof onStateListener) => { + onStateListener = listener + return () => { + onStateListener = null + } + }), + onMetadata: vi.fn(() => () => undefined), + }, + }, + }) + + function Harness(): null { + useWorkspaceCanvasPtyTaskCompletion({ + setNodes, + }) + return null + } + + render() + + onStateListener?.({ sessionId: 'agent-session-1', state: 'running' }) + + await waitFor(() => { + expect(setNodes).toHaveBeenCalledTimes(1) + }) + + expect(setNodes.mock.calls[0]?.[1]).toEqual({ syncLayout: false }) + }) +}) diff --git a/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx b/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx index e4845b14..210eba4c 100644 --- a/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx +++ b/tests/unit/contexts/terminalNode.hydration-buffer.spec.tsx @@ -36,20 +36,8 @@ vi.mock('@xterm/xterm', () => { public options: { fontSize: number; theme?: unknown } = { fontSize: 13 } public written: string[] = [] public refreshCalls = 0 - public buffer = { - active: { type: 'normal' as 'normal' | 'alternate' }, - onBufferChange: (listener: (buffer: { type: 'normal' | 'alternate' }) => void) => { - this.bufferChangeListener = listener - return { - dispose: () => { - this.bufferChangeListener = null - }, - } - }, - } private dataListener: ((data: string) => void) | null = null private binaryListener: ((data: string) => void) | null = null - private bufferChangeListener: ((buffer: { type: 'normal' | 'alternate' }) => void) | null = null public constructor(options?: { cols?: number; rows?: number; theme?: unknown }) { MockTerminal.lastInstance = this @@ -100,11 +88,6 @@ vi.mock('@xterm/xterm', () => { callback?.() } - public emitBufferChange(type: 'normal' | 'alternate'): void { - this.buffer.active = { type } - this.bufferChangeListener?.({ type }) - } - public emitBinary(data: string): void { this.binaryListener?.(data) } @@ -403,70 +386,4 @@ describe('TerminalNode hydration buffering', () => { }) }) }) - - it('marks alternate-screen agent nodes so the native xterm cursor can be hidden', async () => { - if (typeof window.ResizeObserver === 'undefined') { - window.ResizeObserver = class ResizeObserver { - public observe(): void {} - public disconnect(): void {} - public unobserve(): void {} - } - } - - Object.defineProperty(window, 'opencoveApi', { - configurable: true, - writable: true, - value: { - meta: { - isTest: true, - }, - pty: { - attach: vi.fn(async () => undefined), - detach: vi.fn(async () => undefined), - snapshot: vi.fn(async () => ({ data: '' })), - onData: vi.fn(() => () => undefined), - onExit: vi.fn(() => () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - }, - }, - }) - - const { TerminalNode } = - await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') - - const { container } = render( - undefined} - onResize={() => undefined} - />, - ) - - const terminalContainer = container.querySelector('.terminal-node__terminal') - expect(terminalContainer).not.toHaveAttribute('data-cove-agent-alt-buffer') - - const { __getLastTerminal } = await import('@xterm/xterm') - __getLastTerminal()?.emitBufferChange('alternate') - - await waitFor(() => { - expect(terminalContainer).toHaveAttribute('data-cove-agent-alt-buffer', 'true') - }) - - __getLastTerminal()?.emitBufferChange('normal') - - await waitFor(() => { - expect(terminalContainer).not.toHaveAttribute('data-cove-agent-alt-buffer') - }) - }) }) From 64a991f81b48572f676f3cf4f7c27897a3d21de1 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 21:12:56 +0800 Subject: [PATCH 05/22] Remove abandoned agent cursor visibility helper --- .../terminalNode/agentCursorVisibility.ts | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts deleted file mode 100644 index 295585cc..00000000 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/agentCursorVisibility.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Terminal } from '@xterm/xterm' - -function syncAgentAltBufferMarker( - container: HTMLElement, - isAgentNode: boolean, - bufferType: 'normal' | 'alternate', -): void { - if (!isAgentNode || bufferType !== 'alternate') { - container.removeAttribute('data-cove-agent-alt-buffer') - return - } - - container.setAttribute('data-cove-agent-alt-buffer', 'true') -} - -export function bindAgentCursorVisibility({ - terminal, - container, - isAgentNode, -}: { - terminal: Terminal - container: HTMLElement - isAgentNode: boolean -}): () => void { - syncAgentAltBufferMarker(container, isAgentNode, terminal.buffer.active.type) - - if (!isAgentNode) { - return () => undefined - } - - const disposable = terminal.buffer.onBufferChange(buffer => { - syncAgentAltBufferMarker(container, true, buffer.type) - }) - - return () => { - disposable.dispose() - container.removeAttribute('data-cove-agent-alt-buffer') - } -} From 381b4cfbeed42b5bde8ca8e58fe881c15f0a6586 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 22:15:06 +0800 Subject: [PATCH 06/22] fix: unify agent terminal launch semantics --- src/app/preload/index.d.ts | 1 + src/app/preload/index.ts | 18 +++ .../agent/presentation/main-ipc/register.ts | 46 +++--- .../renderer/components/TerminalNode.tsx | 2 + src/shared/contracts/dto/terminal.ts | 5 + ...provedWorkspaceGuard.agent.windows.spec.ts | 142 ++++++++++++++++++ .../ipc/ipcApprovedWorkspaceGuard.spec.ts | 73 --------- .../unit/contexts/terminalNode.theme.spec.tsx | 47 +++++- 8 files changed, 233 insertions(+), 101 deletions(-) create mode 100644 tests/contract/ipc/ipcApprovedWorkspaceGuard.agent.windows.spec.ts diff --git a/src/app/preload/index.d.ts b/src/app/preload/index.d.ts index fcbd56b1..0d3f9f63 100644 --- a/src/app/preload/index.d.ts +++ b/src/app/preload/index.d.ts @@ -78,6 +78,7 @@ export interface OpenCoveApi { isTest: boolean allowWhatsNewInTests: boolean platform: string + windowsPty: import('../../shared/contracts/dto').TerminalWindowsPty | null } windowChrome: { setTheme: (payload: SetWindowChromeThemeInput) => Promise diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index 2f20fdaf..bebba7fd 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' +import os from 'node:os' import { IPC_CHANNELS } from '../../shared/contracts/ipc' import type { AttachTerminalInput, @@ -76,12 +77,29 @@ import { invokeIpc } from './ipcInvoke' type UnsubscribeFn = () => void +function resolveWindowsPtyMeta(): { backend: 'conpty'; buildNumber: number } | null { + if (process.platform !== 'win32') { + return null + } + + const build = Number.parseInt(os.release().split('.')[2] ?? '', 10) + if (!Number.isFinite(build) || build <= 0) { + return null + } + + return { + backend: 'conpty', + buildNumber: build, + } +} + // Custom APIs for renderer const opencoveApi = { meta: { isTest: process.env.NODE_ENV === 'test', allowWhatsNewInTests: process.env.OPENCOVE_TEST_WHATS_NEW === '1', platform: process.platform, + windowsPty: resolveWindowsPtyMeta(), }, windowChrome: { setTheme: (payload: SetWindowChromeThemeInput): Promise => diff --git a/src/contexts/agent/presentation/main-ipc/register.ts b/src/contexts/agent/presentation/main-ipc/register.ts index dad3d682..e56c1418 100644 --- a/src/contexts/agent/presentation/main-ipc/register.ts +++ b/src/contexts/agent/presentation/main-ipc/register.ts @@ -244,31 +244,27 @@ export function registerAgentIpcHandlers( } : undefined - const resolvedSpawn = - normalized.profileId && normalized.profileId.trim().length > 0 - ? await terminalProfileResolver.resolveCommandSpawn({ - cwd: normalized.cwd, - profileId: normalized.profileId, - command, - args, - ...(sessionEnv ? { env: sessionEnv } : {}), - }) - : await (async () => { - const resolvedInvocation = await resolveAgentCliInvocation({ - command, - args, - }) - - return { - command: resolvedInvocation.command, - args: resolvedInvocation.args, - cwd: normalized.cwd, - env: sessionEnv ? { ...process.env, ...sessionEnv } : undefined, - profileId: null, - runtimeKind: - process.platform === 'win32' ? ('windows' as const) : ('posix' as const), - } - })() + const resolvedInvocation = await resolveAgentCliInvocation({ + command, + args, + }) + + const resolvedSpawn = testStub + ? { + command: resolvedInvocation.command, + args: resolvedInvocation.args, + cwd: normalized.cwd, + env: sessionEnv ? { ...process.env, ...sessionEnv } : undefined, + profileId: normalized.profileId ?? null, + runtimeKind: process.platform === 'win32' ? ('windows' as const) : ('posix' as const), + } + : await terminalProfileResolver.resolveCommandSpawn({ + cwd: normalized.cwd, + profileId: normalized.profileId, + command: resolvedInvocation.command, + args: resolvedInvocation.args, + ...(sessionEnv ? { env: sessionEnv } : {}), + }) const { sessionId } = await ptyRuntime.spawnSession({ cwd: resolvedSpawn.cwd, diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index 2b0976ff..a0c52b23 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -158,6 +158,7 @@ export function TerminalNode({ const scrollbackBuffer = scrollbackBufferRef.current const initialTerminalTheme = resolveTerminalTheme(terminalThemeMode) const resolvedTerminalUiTheme = resolveTerminalUiTheme(terminalThemeMode) + const windowsPty = window.opencoveApi.meta?.windowsPty ?? null const terminal = new Terminal({ cursorBlink: true, fontFamily: @@ -166,6 +167,7 @@ export function TerminalNode({ allowProposedApi: true, convertEol: true, scrollback: 5000, + ...(windowsPty ? { windowsPty } : {}), ...(initialDimensions ?? {}), }) const fitAddon = new FitAddon() diff --git a/src/shared/contracts/dto/terminal.ts b/src/shared/contracts/dto/terminal.ts index 987d47c2..be516b52 100644 --- a/src/shared/contracts/dto/terminal.ts +++ b/src/shared/contracts/dto/terminal.ts @@ -2,6 +2,11 @@ export interface PseudoTerminalSession { sessionId: string } +export interface TerminalWindowsPty { + backend: 'conpty' + buildNumber: number +} + export type TerminalRuntimeKind = 'windows' | 'wsl' | 'posix' export interface TerminalProfile { diff --git a/tests/contract/ipc/ipcApprovedWorkspaceGuard.agent.windows.spec.ts b/tests/contract/ipc/ipcApprovedWorkspaceGuard.agent.windows.spec.ts new file mode 100644 index 00000000..029730b7 --- /dev/null +++ b/tests/contract/ipc/ipcApprovedWorkspaceGuard.agent.windows.spec.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '../../../src/shared/constants/ipc' +import type { ApprovedWorkspaceStore } from '../../../src/contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore' +import type { PtyRuntime } from '../../../src/contexts/terminal/presentation/main-ipc/runtime' +import { invokeHandledIpc } from './ipcTestUtils' + +function createIpcHarness() { + const handlers = new Map unknown>() + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel) + }), + } + + return { handlers, ipcMain } +} + +function createApprovedWorkspaceStoreMock(): ApprovedWorkspaceStore { + return { + registerRoot: vi.fn(async () => undefined), + isPathApproved: vi.fn(async () => true), + } +} + +function createPtyRuntimeMock(): PtyRuntime { + return { + spawnSession: vi.fn(() => ({ sessionId: 'session-1' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + attach: vi.fn(), + detach: vi.fn(), + snapshot: vi.fn(() => ''), + startSessionStateWatcher: vi.fn(), + dispose: vi.fn(), + } +} + +const originalPlatform = process.platform + +afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }) + vi.doUnmock('node:child_process') +}) + +describe('IPC approved workspace guards on Windows', () => { + it('routes Windows agent launches through the default terminal profile even for .cmd shims', async () => { + vi.resetModules() + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }) + + const previousNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + try { + const { handlers, ipcMain } = createIpcHarness() + vi.doMock('electron', () => ({ ipcMain })) + vi.doMock('node:child_process', () => { + const execFile = vi.fn((file, args, options, callback) => { + const cb = typeof options === 'function' ? options : callback + if (file === 'where.exe' && args?.[0] === 'powershell.exe') { + cb?.(null, 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n', '') + return + } + + if (file === 'where.exe' && args?.[0] === 'powershell') { + cb?.(null, 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\r\n', '') + return + } + + if (file === 'where.exe' && args?.[0] === 'codex') { + cb?.(null, 'C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd\r\n', '') + return + } + + cb?.(new Error(`not found: ${String(file)} ${String(args?.[0] ?? '')}`), '', '') + }) + + return { + execFile, + default: { + execFile, + }, + } + }) + + const runtime = createPtyRuntimeMock() + const store = createApprovedWorkspaceStoreMock() + + const { registerAgentIpcHandlers } = + await import('../../../src/contexts/agent/presentation/main-ipc/register') + registerAgentIpcHandlers(runtime, store) + + const launchHandler = handlers.get(IPC_CHANNELS.agentLaunch) + expect(launchHandler).toBeTypeOf('function') + + const result = await invokeHandledIpc(launchHandler, null, { + provider: 'codex', + cwd: 'C:\\approved', + prompt: 'hello', + cols: 80, + rows: 24, + }) + + expect(runtime.spawnSession).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + cwd: 'C:\\approved', + args: expect.arrayContaining(['-NoLogo', '-Command']), + }), + ) + + const spawnOptions = vi.mocked(runtime.spawnSession).mock.calls[0]?.[0] + expect(spawnOptions?.args[2]).toContain( + 'C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd', + ) + expect(result).toEqual( + expect.objectContaining({ + command: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + profileId: 'powershell', + runtimeKind: 'windows', + args: expect.arrayContaining(['-NoLogo', '-Command']), + }), + ) + expect(result.args[2]).toContain('C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd') + } finally { + if (typeof previousNodeEnv === 'string') { + process.env.NODE_ENV = previousNodeEnv + } else { + delete process.env.NODE_ENV + } + } + }) +}) diff --git a/tests/contract/ipc/ipcApprovedWorkspaceGuard.spec.ts b/tests/contract/ipc/ipcApprovedWorkspaceGuard.spec.ts index 07f85916..9146ed35 100644 --- a/tests/contract/ipc/ipcApprovedWorkspaceGuard.spec.ts +++ b/tests/contract/ipc/ipcApprovedWorkspaceGuard.spec.ts @@ -204,79 +204,6 @@ describe('IPC approved workspace guards', () => { } }) - it('wraps Windows agent launches through cmd.exe when the CLI resolves to a .cmd shim', async () => { - vi.resetModules() - Object.defineProperty(process, 'platform', { - value: 'win32', - configurable: true, - }) - - const previousNodeEnv = process.env.NODE_ENV - process.env.NODE_ENV = 'test' - - try { - const { handlers, ipcMain } = createIpcHarness() - vi.doMock('electron', () => ({ ipcMain })) - vi.doMock('node:child_process', () => { - const execFile = vi.fn((_file, _args, options, callback) => { - const cb = typeof options === 'function' ? options : callback - cb?.(null, 'C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd\r\n', '') - }) - return { - execFile, - default: { - execFile, - }, - } - }) - - const runtime = createPtyRuntimeMock() - const store = createApprovedWorkspaceStoreMock({ isPathApproved: true }) - - const { registerAgentIpcHandlers } = - await import('../../../src/contexts/agent/presentation/main-ipc/register') - registerAgentIpcHandlers(runtime, store) - - const launchHandler = handlers.get(IPC_CHANNELS.agentLaunch) - expect(launchHandler).toBeTypeOf('function') - - const result = await invokeHandledIpc(launchHandler, null, { - provider: 'codex', - cwd: '/approved', - prompt: 'hello', - cols: 80, - rows: 24, - }) - - expect(runtime.spawnSession).toHaveBeenCalledWith( - expect.objectContaining({ - command: 'cmd.exe', - args: expect.arrayContaining([ - '/d', - '/c', - 'C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd', - ]), - }), - ) - expect(result).toEqual( - expect.objectContaining({ - command: 'cmd.exe', - args: expect.arrayContaining([ - '/d', - '/c', - 'C:\\Users\\deadwave\\AppData\\Roaming\\npm\\codex.cmd', - ]), - }), - ) - } finally { - if (typeof previousNodeEnv === 'string') { - process.env.NODE_ENV = previousNodeEnv - } else { - delete process.env.NODE_ENV - } - } - }) - it('starts agent state watcher in the background without blocking launch', async () => { vi.resetModules() diff --git a/tests/unit/contexts/terminalNode.theme.spec.tsx b/tests/unit/contexts/terminalNode.theme.spec.tsx index e47e2388..214605b5 100644 --- a/tests/unit/contexts/terminalNode.theme.spec.tsx +++ b/tests/unit/contexts/terminalNode.theme.spec.tsx @@ -14,16 +14,16 @@ vi.mock('@xterm/xterm', () => { public cols = 80 public rows = 24 - public options: { fontSize: number; theme?: unknown } = { fontSize: 13 } + public options: Record = { fontSize: 13 } public refreshCalls = 0 - public constructor(options?: { cols?: number; rows?: number; theme?: unknown }) { + public constructor(options?: Record & { cols?: number; rows?: number }) { MockTerminal.lastInstance = this this.cols = options?.cols ?? 80 this.rows = options?.rows ?? 24 this.options = { ...this.options, - ...(options?.theme ? { theme: options.theme } : {}), + ...(options ?? {}), } } @@ -115,6 +115,8 @@ function installPtyApiMock() { value: { meta: { isTest: true, + platform: 'darwin', + windowsPty: null, }, pty: { attach: vi.fn(async () => undefined), @@ -284,4 +286,43 @@ describe('TerminalNode theme behavior', () => { ) }) }) + + it('passes Windows PTY compatibility metadata into xterm when available', async () => { + installResizeObserverMock() + installPtyApiMock() + window.opencoveApi.meta.platform = 'win32' + window.opencoveApi.meta.windowsPty = { + backend: 'conpty', + buildNumber: 19045, + } + + const { TerminalNode } = + await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') + + render( + undefined} + onResize={() => undefined} + />, + ) + + const { __getLastTerminal } = await import('@xterm/xterm') + await waitFor(() => { + expect(__getLastTerminal()?.options.windowsPty).toEqual({ + backend: 'conpty', + buildNumber: 19045, + }) + }) + }) }) From 056d8d9a6933488cd4ee6ab8f31e53336e941632 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 22:34:40 +0800 Subject: [PATCH 07/22] fix: keep preload sandbox-compatible --- src/app/preload/index.ts | 5 +++-- tests/e2e/app-shell.preload-sandbox.spec.ts | 23 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/app-shell.preload-sandbox.spec.ts diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index bebba7fd..5a9a3c44 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -1,5 +1,4 @@ import { contextBridge, ipcRenderer } from 'electron' -import os from 'node:os' import { IPC_CHANNELS } from '../../shared/contracts/ipc' import type { AttachTerminalInput, @@ -82,7 +81,9 @@ function resolveWindowsPtyMeta(): { backend: 'conpty'; buildNumber: number } | n return null } - const build = Number.parseInt(os.release().split('.')[2] ?? '', 10) + const systemVersion = + typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : '' + const build = Number.parseInt(systemVersion.split('.')[2] ?? '', 10) if (!Number.isFinite(build) || build <= 0) { return null } diff --git a/tests/e2e/app-shell.preload-sandbox.spec.ts b/tests/e2e/app-shell.preload-sandbox.spec.ts new file mode 100644 index 00000000..6ee7c37b --- /dev/null +++ b/tests/e2e/app-shell.preload-sandbox.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test' +import { launchApp } from './workspace-canvas.helpers' + +test.describe('App Shell - Sandboxed Preload', () => { + test('renders the shell when the renderer sandbox is enabled', async () => { + const { electronApp, window } = await launchApp({ + windowMode: 'offscreen', + env: { + OPENCOVE_E2E_FORCE_RENDERER_SANDBOX: '1', + }, + }) + + try { + await expect( + window.locator('[data-testid="app-header-toggle-primary-sidebar"]'), + ).toBeVisible() + await expect(window.locator('[data-testid="app-header-settings"]')).toBeVisible() + await expect(window.locator('.workspace-main')).toBeVisible() + } finally { + await electronApp.close() + } + }) +}) From d01b69937ab0c0d71892b79b99617efc09aa1b63 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Sat, 28 Mar 2026 23:57:23 +0800 Subject: [PATCH 08/22] chore: add Win10 codex terminal diagnostics --- docs/WIN10_CODEX_SCROLL_DIAGNOSTICS.md | 168 ++++++++++++++++++ .../ipc/registerDiagnosticsIpcHandlers.ts | 44 +++++ src/app/main/ipc/registerIpcHandlers.ts | 2 + src/app/preload/index.d.ts | 5 + src/app/preload/index.ts | 7 + .../renderer/components/TerminalNode.tsx | 76 ++++---- .../components/terminalNode/diagnostics.ts | 102 +++++++++++ .../terminalNode/hydrateFromSnapshot.ts | 60 +++++++ .../terminalNode/registerDiagnostics.ts | 100 +++++++++++ src/shared/contracts/dto/debug.ts | 29 +++ src/shared/contracts/dto/index.ts | 1 + src/shared/contracts/ipc/channels.ts | 1 + .../contexts/terminalNode.diagnostics.spec.ts | 66 +++++++ 13 files changed, 620 insertions(+), 41 deletions(-) create mode 100644 docs/WIN10_CODEX_SCROLL_DIAGNOSTICS.md create mode 100644 src/app/main/ipc/registerDiagnosticsIpcHandlers.ts create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/diagnostics.ts create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/registerDiagnostics.ts create mode 100644 src/shared/contracts/dto/debug.ts create mode 100644 tests/unit/contexts/terminalNode.diagnostics.spec.ts diff --git a/docs/WIN10_CODEX_SCROLL_DIAGNOSTICS.md b/docs/WIN10_CODEX_SCROLL_DIAGNOSTICS.md new file mode 100644 index 00000000..f1b4abf1 --- /dev/null +++ b/docs/WIN10_CODEX_SCROLL_DIAGNOSTICS.md @@ -0,0 +1,168 @@ +# Win10 Codex Scroll Diagnostics + +Date: 2026-03-28 +Scope: Windows 10 only, focused on Codex running inside an OpenCove agent/terminal window. + +## Problem Statement + +Observed user report: + +- On Windows 10, when Codex is launched inside OpenCove's embedded terminal/agent window, the Codex UI can lose normal scrollback behavior. +- The same Codex workflow behaves correctly in: + - VS Code's integrated terminal + - a native local terminal outside OpenCove +- The symptom is not "all wheel input is broken". The narrower symptom is: + - the Codex TUI does not expose a normal vertical scrollbar / scrollback path inside OpenCove on Win10 + +## Current Working Theory + +The strongest current explanation is a three-way interaction: + +1. Codex switches into a full-screen TUI / alternate-screen workflow. +2. Windows 10 ConPTY has weaker scrollback/reflow behavior than newer Windows builds. +3. OpenCove currently uses the xterm.js + node-pty baseline, but not VS Code's deeper Windows terminal compatibility stack. + +This means the likely failing boundary is not basic shell launch anymore. It is: + +- ConPTY buffer semantics +- alternate-screen / mouse mode behavior +- xterm viewport / scrollbar state inside OpenCove's embedded terminal + +## Why VS Code Can Behave Better + +OpenCove already aligned one important part with VS Code: + +- agent launch now goes through one terminal-profile normalization path +- Windows PTY metadata is propagated into xterm's `windowsPty` + +But VS Code still has a much larger Windows terminal stack on top of that: + +- PTY host orchestration +- Windows backend heuristics +- shell integration +- more mature ConPTY behavior handling + +References: + +- VS Code terminal profiles: + - https://code.visualstudio.com/docs/terminal/profiles +- VS Code advanced terminal docs: + - https://code.visualstudio.com/docs/terminal/advanced +- VS Code terminal troubleshooting: + - https://code.visualstudio.com/docs/supporting/troubleshoot-terminal-launch +- xterm `windowsPty` option: + - https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/ +- node-pty README: + - https://github.com/microsoft/node-pty + +## What This Branch Adds + +This branch adds an opt-in diagnostics path for terminal nodes. + +When enabled, OpenCove writes structured JSON lines to the process stdout with: + +- `init` +- `hydrated` +- `resize` +- `wheel` +- `scroll` + +Each line includes: + +- node/session identity +- terminal kind (`terminal` / `agent`) +- xterm buffer mode: + - `normal` + - `alternate` + - `unknown` +- active buffer values: + - `activeBaseY` + - `activeViewportY` + - `activeLength` +- DOM viewport facts: + - `hasViewport` + - `hasVerticalScrollbar` + - `viewportScrollTop` + - `viewportScrollHeight` + - `viewportClientHeight` + +Log prefix: + +```text +[opencove-terminal-diagnostics] +``` + +## How To Run On Windows 10 + +Use a terminal, not a desktop icon, so stdout remains visible. + +PowerShell: + +```powershell +$env:OPENCOVE_TERMINAL_DIAGNOSTICS='1' +pnpm dev +``` + +If testing a production build from terminal, launch the built executable from the same shell with `OPENCOVE_TERMINAL_DIAGNOSTICS=1` set first. + +## What To Look For + +### Case A: Wheel never reaches the xterm viewport + +Expected signal: + +- no `wheel` logs appear while scrolling over the Codex window + +Interpretation: + +- event routing is wrong before xterm receives the gesture +- likely a DOM/event capture issue rather than ConPTY scrollback + +### Case B: Wheel arrives, but buffer stays `alternate` with no meaningful scrollback + +Expected signal: + +- `wheel` logs appear +- `bufferKind` remains `alternate` +- `activeBaseY` / `activeViewportY` do not move meaningfully +- `hasVerticalScrollbar` stays `false` + +Interpretation: + +- Codex is running in an alternate-screen mode where normal scrollback is not materialized +- Win10 ConPTY compatibility is the more likely bottleneck + +### Case C: Scrollbar exists but viewport never scrolls + +Expected signal: + +- `hasVerticalScrollbar` is `true` +- `wheel` logs appear +- `scroll` logs never appear + +Interpretation: + +- wheel reaches the terminal surface +- viewport scrolling is not being converted into actual xterm viewport movement + +## Expected Next Step After Win10 Manual Test + +After collecting logs from a real Windows 10 machine, the next change should be chosen from evidence, not guesswork: + +1. If wheel never arrives: + - fix event routing / capture path +2. If wheel arrives but alternate buffer never exposes scrollback: + - compare against VS Code's Windows terminal behavior more directly + - evaluate whether Codex needs a different Windows launch/runtime mode inside OpenCove +3. If scrollbar exists but viewport does not move: + - inspect xterm viewport state and mouse/wheel integration on Win10 specifically + +## Non-Goals Of This Document + +This document does not claim the root cause is fully proven yet. + +It documents: + +- the narrowed hypothesis +- the instrumentation added in this branch +- the exact evidence we need from a real Windows 10 machine diff --git a/src/app/main/ipc/registerDiagnosticsIpcHandlers.ts b/src/app/main/ipc/registerDiagnosticsIpcHandlers.ts new file mode 100644 index 00000000..35227be7 --- /dev/null +++ b/src/app/main/ipc/registerDiagnosticsIpcHandlers.ts @@ -0,0 +1,44 @@ +import { ipcMain } from 'electron' +import { IPC_CHANNELS } from '../../../shared/contracts/ipc' +import type { TerminalDiagnosticsLogInput } from '../../../shared/contracts/dto' +import type { IpcRegistrationDisposable } from './types' + +function isTerminalDiagnosticsEnabled(): boolean { + return process.env['OPENCOVE_TERMINAL_DIAGNOSTICS'] === '1' +} + +function writeTerminalDiagnosticsLine(payload: TerminalDiagnosticsLogInput): void { + const line = JSON.stringify({ + ts: new Date().toISOString(), + ...payload, + }) + + process.stdout.write(`[opencove-terminal-diagnostics] ${line}\n`) +} + +export function registerDiagnosticsIpcHandlers(): IpcRegistrationDisposable { + if (typeof ipcMain.on !== 'function' || typeof ipcMain.removeListener !== 'function') { + return { + dispose: () => undefined, + } + } + + const handleTerminalDiagnosticsLog = ( + _event: Electron.IpcMainEvent, + payload: TerminalDiagnosticsLogInput, + ): void => { + if (!isTerminalDiagnosticsEnabled()) { + return + } + + writeTerminalDiagnosticsLine(payload) + } + + ipcMain.on(IPC_CHANNELS.terminalDiagnosticsLog, handleTerminalDiagnosticsLog) + + return { + dispose: () => { + ipcMain.removeListener(IPC_CHANNELS.terminalDiagnosticsLog, handleTerminalDiagnosticsLog) + }, + } +} diff --git a/src/app/main/ipc/registerIpcHandlers.ts b/src/app/main/ipc/registerIpcHandlers.ts index de6dacd2..b48e41d3 100644 --- a/src/app/main/ipc/registerIpcHandlers.ts +++ b/src/app/main/ipc/registerIpcHandlers.ts @@ -20,6 +20,7 @@ import { createPersistenceStore } from '../../../platform/persistence/sqlite/Per import { registerPersistenceIpcHandlers } from '../../../platform/persistence/sqlite/ipc/register' import { registerWindowChromeIpcHandlers } from './registerWindowChromeIpcHandlers' import { registerWindowMetricsIpcHandlers } from './registerWindowMetricsIpcHandlers' +import { registerDiagnosticsIpcHandlers } from './registerDiagnosticsIpcHandlers' export type { IpcRegistrationDisposable } from './types' @@ -65,6 +66,7 @@ export function registerIpcHandlers(deps?: { registerIntegrationIpcHandlers(approvedWorkspaces), registerWindowChromeIpcHandlers(), registerWindowMetricsIpcHandlers(), + registerDiagnosticsIpcHandlers(), registerPtyIpcHandlers(ptyRuntime, approvedWorkspaces), registerAgentIpcHandlers(ptyRuntime, approvedWorkspaces), registerTaskIpcHandlers(approvedWorkspaces), diff --git a/src/app/preload/index.d.ts b/src/app/preload/index.d.ts index 0d3f9f63..019ba5f2 100644 --- a/src/app/preload/index.d.ts +++ b/src/app/preload/index.d.ts @@ -62,6 +62,7 @@ import type { WriteWorkspaceStateRawInput, WriteTerminalInput, DeleteCanvasImageInput, + TerminalDiagnosticsLogInput, ReadDirectoryInput, ReadDirectoryResult, ReadFileTextInput, @@ -77,9 +78,13 @@ export interface OpenCoveApi { meta: { isTest: boolean allowWhatsNewInTests: boolean + enableTerminalDiagnostics?: boolean platform: string windowsPty: import('../../shared/contracts/dto').TerminalWindowsPty | null } + debug?: { + logTerminalDiagnostics: (payload: TerminalDiagnosticsLogInput) => void + } windowChrome: { setTheme: (payload: SetWindowChromeThemeInput) => Promise } diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index 5a9a3c44..1789a298 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -64,6 +64,7 @@ import type { WriteWorkspaceStateRawInput, WriteTerminalInput, DeleteCanvasImageInput, + TerminalDiagnosticsLogInput, ReadDirectoryInput, ReadDirectoryResult, ReadFileTextInput, @@ -99,9 +100,15 @@ const opencoveApi = { meta: { isTest: process.env.NODE_ENV === 'test', allowWhatsNewInTests: process.env.OPENCOVE_TEST_WHATS_NEW === '1', + enableTerminalDiagnostics: process.env.OPENCOVE_TERMINAL_DIAGNOSTICS === '1', platform: process.platform, windowsPty: resolveWindowsPtyMeta(), }, + debug: { + logTerminalDiagnostics: (payload: TerminalDiagnosticsLogInput): void => { + ipcRenderer.send(IPC_CHANNELS.terminalDiagnosticsLog, payload) + }, + }, windowChrome: { setTheme: (payload: SetWindowChromeThemeInput): Promise => invokeIpc(IPC_CHANNELS.windowChromeSetTheme, payload), diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index a0c52b23..def81b32 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -12,7 +12,6 @@ import { } from './terminalNode/commandInput' import { createPtyWriteQueue, handleTerminalCustomKeyEvent } from './terminalNode/inputBridge' import { registerTerminalLayoutSync } from './terminalNode/layoutSync' -import { mergeScrollbackSnapshots, resolveScrollbackDelta } from './terminalNode/scrollback' import { clearCachedTerminalScreenStateInvalidation, getCachedTerminalScreenState, @@ -25,6 +24,7 @@ import { resolveTerminalNodeFrameStyle } from './terminalNode/nodeFrameStyle' import { resolveTerminalTheme, resolveTerminalUiTheme } from './terminalNode/theme' import { registerTerminalSelectionTestHandle } from './terminalNode/testHarness' import { patchXtermMouseServiceWithRetry } from './terminalNode/patchXtermMouseService' +import { registerTerminalDiagnostics } from './terminalNode/registerDiagnostics' import { useTerminalThemeApplier } from './terminalNode/useTerminalThemeApplier' import { useTerminalBodyClickFallback } from './terminalNode/useTerminalBodyClickFallback' import { useTerminalFind } from './terminalNode/useTerminalFind' @@ -33,6 +33,7 @@ import { useTerminalScrollback } from './terminalNode/useScrollback' import { resolveInitialTerminalDimensions } from './terminalNode/initialDimensions' import { revealHydratedTerminal } from './terminalNode/revealHydratedTerminal' import { createTerminalOutputScheduler } from './terminalNode/outputScheduler' +import { hydrateTerminalFromSnapshot } from './terminalNode/hydrateFromSnapshot' import { selectDragSurfaceSelectionMode, selectViewportInteractionActive, @@ -159,6 +160,9 @@ export function TerminalNode({ const initialTerminalTheme = resolveTerminalTheme(terminalThemeMode) const resolvedTerminalUiTheme = resolveTerminalUiTheme(terminalThemeMode) const windowsPty = window.opencoveApi.meta?.windowsPty ?? null + const diagnosticsEnabled = window.opencoveApi.meta?.enableTerminalDiagnostics === true + const logTerminalDiagnostics = + window.opencoveApi.debug?.logTerminalDiagnostics ?? (() => undefined) const terminal = new Terminal({ cursorBlink: true, fontFamily: @@ -215,6 +219,18 @@ export function TerminalNode({ terminal.focus() } } + const terminalDiagnostics = registerTerminalDiagnostics({ + enabled: diagnosticsEnabled, + emit: logTerminalDiagnostics, + nodeId, + sessionId, + nodeKind: kind === 'agent' ? 'agent' : 'terminal', + title, + terminal, + container: containerRef.current, + terminalThemeMode, + windowsPty, + }) let isDisposed = false let shouldForwardTerminalData = false @@ -310,6 +326,10 @@ export function TerminalNode({ } markScrollbackDirty(true) + terminalDiagnostics.logHydrated({ + rawSnapshotLength: rawSnapshot.length, + bufferedExitCode, + }) revealHydratedTerminal(syncTerminalSize, () => { if (!isDisposed) { isTerminalHydratedRef.current = true @@ -318,48 +338,19 @@ export function TerminalNode({ }) } - const hydrateFromSnapshot = async () => { - await attachPromise.catch(() => undefined) - - const persistedSnapshot = scrollbackBuffer.snapshot() - const cachedSerializedScreen = cachedScreenState?.serialized ?? '' - const baseRawSnapshot = - cachedScreenState && cachedScreenState.rawSnapshot.length > 0 - ? cachedScreenState.rawSnapshot - : persistedSnapshot - let restoredPayload = - cachedSerializedScreen.length > 0 ? cachedSerializedScreen : persistedSnapshot - let rawSnapshot = baseRawSnapshot - - try { - const snapshot = await window.opencoveApi.pty.snapshot({ sessionId }) - if (cachedSerializedScreen.length > 0) { - restoredPayload = `${cachedSerializedScreen}${resolveScrollbackDelta(baseRawSnapshot, snapshot.data)}` - rawSnapshot = mergeScrollbackSnapshots(baseRawSnapshot, snapshot.data) - } else { - rawSnapshot = mergeScrollbackSnapshots(persistedSnapshot, snapshot.data) - restoredPayload = rawSnapshot - } - } catch { - rawSnapshot = baseRawSnapshot - } - - if (isDisposed) { - return - } - - if (restoredPayload.length > 0) { - terminal.write(restoredPayload, () => { - shouldForwardTerminalData = true - finalizeHydration(rawSnapshot) - }) - } else { + void hydrateTerminalFromSnapshot({ + attachPromise, + sessionId, + terminal, + cachedScreenState, + persistedSnapshot: scrollbackBuffer.snapshot(), + takePtySnapshot: payload => window.opencoveApi.pty.snapshot(payload), + isDisposed: () => isDisposed, + finalizeHydration: rawSnapshot => { shouldForwardTerminalData = true finalizeHydration(rawSnapshot) - } - } - - void hydrateFromSnapshot() + }, + }) const resizeObserver = new ResizeObserver(() => { syncTerminalSize() @@ -403,6 +394,7 @@ export function TerminalNode({ const detachPromise = ptyWithOptionalAttach.detach?.({ sessionId }) void detachPromise?.catch(() => undefined) disposeLayoutSync() + terminalDiagnostics.dispose() window.removeEventListener('opencove-theme-changed', handleThemeChange) resizeObserver.disconnect() dataDisposable.dispose() @@ -436,6 +428,8 @@ export function TerminalNode({ sessionId, syncTerminalSize, terminalThemeMode, + title, + kind, ]) useEffect(() => { diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/diagnostics.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/diagnostics.ts new file mode 100644 index 00000000..37c8a3e6 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/diagnostics.ts @@ -0,0 +1,102 @@ +import type { + TerminalDiagnosticsBufferKind, + TerminalDiagnosticsLogInput, + TerminalDiagnosticsSnapshot, +} from '@shared/contracts/dto' + +interface TerminalBufferStateLike { + baseY?: number + viewportY?: number + length?: number +} + +interface TerminalBufferNamespaceLike { + active?: TerminalBufferStateLike + normal?: TerminalBufferStateLike + alternate?: TerminalBufferStateLike +} + +interface TerminalForDiagnosticsLike { + cols: number + rows: number + buffer?: TerminalBufferNamespaceLike +} + +function toFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function resolveTerminalBufferKind( + terminal: Pick, +): TerminalDiagnosticsBufferKind { + const buffer = terminal.buffer + if (!buffer?.active) { + return 'unknown' + } + + if (buffer.alternate && buffer.active === buffer.alternate) { + return 'alternate' + } + + if (buffer.normal && buffer.active === buffer.normal) { + return 'normal' + } + + return 'unknown' +} + +export function captureTerminalDiagnosticsSnapshot( + terminal: TerminalForDiagnosticsLike, + viewportElement: HTMLElement | null, +): TerminalDiagnosticsSnapshot { + const activeBuffer = terminal.buffer?.active + const scrollbar = + viewportElement?.parentElement?.querySelector( + '.xterm-scrollable-element .scrollbar.vertical', + ) ?? null + + return { + bufferKind: resolveTerminalBufferKind(terminal), + activeBaseY: toFiniteNumber(activeBuffer?.baseY), + activeViewportY: toFiniteNumber(activeBuffer?.viewportY), + activeLength: toFiniteNumber(activeBuffer?.length), + cols: terminal.cols, + rows: terminal.rows, + viewportScrollTop: toFiniteNumber(viewportElement?.scrollTop), + viewportScrollHeight: toFiniteNumber(viewportElement?.scrollHeight), + viewportClientHeight: toFiniteNumber(viewportElement?.clientHeight), + hasViewport: viewportElement instanceof HTMLElement, + hasVerticalScrollbar: scrollbar instanceof HTMLElement, + } +} + +export function createTerminalDiagnosticsLogger({ + enabled, + emit, + base, +}: { + enabled: boolean + emit: (payload: TerminalDiagnosticsLogInput) => void + base: Omit +}): { + log: ( + event: string, + snapshot: TerminalDiagnosticsSnapshot, + details?: TerminalDiagnosticsLogInput['details'], + ) => void +} { + return { + log: (event, snapshot, details) => { + if (!enabled) { + return + } + + emit({ + ...base, + event, + snapshot, + ...(details ? { details } : {}), + }) + }, + } +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts new file mode 100644 index 00000000..042b2f6c --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts @@ -0,0 +1,60 @@ +import type { Terminal } from '@xterm/xterm' +import { mergeScrollbackSnapshots, resolveScrollbackDelta } from './scrollback' +import type { CachedTerminalScreenState } from './screenStateCache' + +export async function hydrateTerminalFromSnapshot({ + attachPromise, + sessionId, + terminal, + cachedScreenState, + persistedSnapshot, + takePtySnapshot, + isDisposed, + finalizeHydration, +}: { + attachPromise: Promise + sessionId: string + terminal: Terminal + cachedScreenState: CachedTerminalScreenState | null + persistedSnapshot: string + takePtySnapshot: (payload: { sessionId: string }) => Promise<{ data: string }> + isDisposed: () => boolean + finalizeHydration: (rawSnapshot: string) => void +}): Promise { + await attachPromise.catch(() => undefined) + + const cachedSerializedScreen = cachedScreenState?.serialized ?? '' + const baseRawSnapshot = + cachedScreenState && cachedScreenState.rawSnapshot.length > 0 + ? cachedScreenState.rawSnapshot + : persistedSnapshot + let restoredPayload = + cachedSerializedScreen.length > 0 ? cachedSerializedScreen : persistedSnapshot + let rawSnapshot = baseRawSnapshot + + try { + const snapshot = await takePtySnapshot({ sessionId }) + if (cachedSerializedScreen.length > 0) { + restoredPayload = `${cachedSerializedScreen}${resolveScrollbackDelta(baseRawSnapshot, snapshot.data)}` + rawSnapshot = mergeScrollbackSnapshots(baseRawSnapshot, snapshot.data) + } else { + rawSnapshot = mergeScrollbackSnapshots(persistedSnapshot, snapshot.data) + restoredPayload = rawSnapshot + } + } catch { + rawSnapshot = baseRawSnapshot + } + + if (isDisposed()) { + return + } + + if (restoredPayload.length > 0) { + terminal.write(restoredPayload, () => { + finalizeHydration(rawSnapshot) + }) + return + } + + finalizeHydration(rawSnapshot) +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/registerDiagnostics.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/registerDiagnostics.ts new file mode 100644 index 00000000..137c8a4c --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/registerDiagnostics.ts @@ -0,0 +1,100 @@ +import type { TerminalWindowsPty, TerminalDiagnosticsLogInput } from '@shared/contracts/dto' +import type { Terminal } from '@xterm/xterm' +import type { TerminalThemeMode } from './theme' +import { captureTerminalDiagnosticsSnapshot, createTerminalDiagnosticsLogger } from './diagnostics' + +export function registerTerminalDiagnostics({ + enabled, + emit, + nodeId, + sessionId, + nodeKind, + title, + terminal, + container, + terminalThemeMode, + windowsPty, +}: { + enabled: boolean + emit: (payload: TerminalDiagnosticsLogInput) => void + nodeId: string + sessionId: string + nodeKind: 'terminal' | 'agent' + title: string + terminal: Terminal + container: HTMLDivElement | null + terminalThemeMode: TerminalThemeMode + windowsPty: TerminalWindowsPty | null +}): { + logHydrated: (details: { rawSnapshotLength: number; bufferedExitCode: number | null }) => void + dispose: () => void +} { + const viewportElement = + container?.querySelector('.xterm-viewport') instanceof HTMLElement + ? (container.querySelector('.xterm-viewport') as HTMLElement) + : null + const diagnostics = createTerminalDiagnosticsLogger({ + enabled, + emit, + base: { + source: 'renderer-terminal', + nodeId, + sessionId, + nodeKind, + title, + }, + }) + + diagnostics.log('init', captureTerminalDiagnosticsSnapshot(terminal, viewportElement), { + windowsPtyBackend: windowsPty?.backend ?? null, + windowsPtyBuild: windowsPty?.buildNumber ?? null, + terminalThemeMode, + }) + + const resizeDisposable = + typeof (terminal as unknown as { onResize?: unknown }).onResize === 'function' + ? ( + terminal as unknown as { + onResize: (listener: (size: { cols: number; rows: number }) => void) => { + dispose: () => void + } + } + ).onResize(size => { + diagnostics.log('resize', captureTerminalDiagnosticsSnapshot(terminal, viewportElement), { + cols: size.cols, + rows: size.rows, + }) + }) + : { dispose: () => undefined } + + const handleViewportWheel = (event: WheelEvent): void => { + diagnostics.log('wheel', captureTerminalDiagnosticsSnapshot(terminal, viewportElement), { + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + }) + } + + const handleViewportScroll = (): void => { + diagnostics.log('scroll', captureTerminalDiagnosticsSnapshot(terminal, viewportElement)) + } + + viewportElement?.addEventListener('wheel', handleViewportWheel, { passive: true }) + viewportElement?.addEventListener('scroll', handleViewportScroll, { passive: true }) + + return { + logHydrated: ({ rawSnapshotLength, bufferedExitCode }) => { + diagnostics.log('hydrated', captureTerminalDiagnosticsSnapshot(terminal, viewportElement), { + rawSnapshotLength, + bufferedExitCode, + }) + }, + dispose: () => { + resizeDisposable.dispose() + viewportElement?.removeEventListener('wheel', handleViewportWheel) + viewportElement?.removeEventListener('scroll', handleViewportScroll) + }, + } +} diff --git a/src/shared/contracts/dto/debug.ts b/src/shared/contracts/dto/debug.ts new file mode 100644 index 00000000..d0523062 --- /dev/null +++ b/src/shared/contracts/dto/debug.ts @@ -0,0 +1,29 @@ +export type TerminalDiagnosticsBufferKind = 'normal' | 'alternate' | 'unknown' +export type TerminalDiagnosticsNodeKind = 'terminal' | 'agent' + +export interface TerminalDiagnosticsSnapshot { + bufferKind: TerminalDiagnosticsBufferKind + activeBaseY: number | null + activeViewportY: number | null + activeLength: number | null + cols: number + rows: number + viewportScrollTop: number | null + viewportScrollHeight: number | null + viewportClientHeight: number | null + hasViewport: boolean + hasVerticalScrollbar: boolean +} + +export type TerminalDiagnosticsDetailValue = string | number | boolean | null + +export interface TerminalDiagnosticsLogInput { + source: 'renderer-terminal' + nodeId: string + sessionId: string + nodeKind: TerminalDiagnosticsNodeKind + title: string + event: string + details?: Record + snapshot: TerminalDiagnosticsSnapshot +} diff --git a/src/shared/contracts/dto/index.ts b/src/shared/contracts/dto/index.ts index 23e8e6c1..6980d5f4 100644 --- a/src/shared/contracts/dto/index.ts +++ b/src/shared/contracts/dto/index.ts @@ -1,6 +1,7 @@ export * from './agent' export * from './clipboard' export * from './controlSurface' +export * from './debug' export * from './filesystem' export * from './error' export * from './integration' diff --git a/src/shared/contracts/ipc/channels.ts b/src/shared/contracts/ipc/channels.ts index 1ed82664..fc0364f4 100644 --- a/src/shared/contracts/ipc/channels.ts +++ b/src/shared/contracts/ipc/channels.ts @@ -37,6 +37,7 @@ export const IPC_CHANNELS = { integrationGithubResolvePullRequests: 'integration:github:resolve-pull-requests', windowChromeSetTheme: 'window-chrome:set-theme', windowMetricsGetDisplayInfo: 'window-metrics:get-display-info', + terminalDiagnosticsLog: 'terminal:diagnostics-log', ptySpawn: 'pty:spawn', ptyListProfiles: 'pty:list-profiles', ptyWrite: 'pty:write', diff --git a/tests/unit/contexts/terminalNode.diagnostics.spec.ts b/tests/unit/contexts/terminalNode.diagnostics.spec.ts new file mode 100644 index 00000000..19cab50a --- /dev/null +++ b/tests/unit/contexts/terminalNode.diagnostics.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + captureTerminalDiagnosticsSnapshot, + resolveTerminalBufferKind, +} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/diagnostics' + +describe('terminal diagnostics helpers', () => { + it('detects the alternate buffer when active matches alternate', () => { + const normal = { baseY: 12, viewportY: 8, length: 120 } + const alternate = { baseY: 0, viewportY: 0, length: 24 } + + expect( + resolveTerminalBufferKind({ + buffer: { + active: alternate, + normal, + alternate, + }, + }), + ).toBe('alternate') + }) + + it('captures viewport and scrollbar facts from the DOM', () => { + const terminalElement = document.createElement('div') + const scrollable = document.createElement('div') + scrollable.className = 'xterm-scrollable-element' + const scrollbar = document.createElement('div') + scrollbar.className = 'scrollbar vertical' + const viewport = document.createElement('div') + viewport.className = 'xterm-viewport' + + Object.defineProperty(viewport, 'scrollTop', { value: 64, configurable: true }) + Object.defineProperty(viewport, 'scrollHeight', { value: 480, configurable: true }) + Object.defineProperty(viewport, 'clientHeight', { value: 160, configurable: true }) + + scrollable.append(scrollbar, viewport) + terminalElement.append(scrollable) + + const snapshot = captureTerminalDiagnosticsSnapshot( + { + cols: 120, + rows: 40, + buffer: { + active: { baseY: 32, viewportY: 20, length: 200 }, + normal: { baseY: 32, viewportY: 20, length: 200 }, + alternate: { baseY: 0, viewportY: 0, length: 40 }, + }, + }, + viewport, + ) + + expect(snapshot).toMatchObject({ + bufferKind: 'unknown', + activeBaseY: 32, + activeViewportY: 20, + activeLength: 200, + cols: 120, + rows: 40, + viewportScrollTop: 64, + viewportScrollHeight: 480, + viewportClientHeight: 160, + hasViewport: true, + hasVerticalScrollbar: true, + }) + }) +}) From 222e3781daa9a09825b3806b9b135452841ff8bc Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 00:59:48 +0800 Subject: [PATCH 09/22] fix: stabilize ANSI screen cache restore --- .../renderer/components/TerminalNode.tsx | 99 +++++----- .../terminalNode/committedScreenState.ts | 117 +++++++++++ .../terminalNode/finalizeHydration.ts | 66 +++++++ .../terminalNode/hydrateFromSnapshot.ts | 3 + .../terminalNode/outputScheduler.ts | 7 +- .../replayBufferedHydrationOutput.ts | 52 +++++ ...rminalNode.screen-cache-committed.spec.tsx | 184 ++++++++++++++++++ 7 files changed, 481 insertions(+), 47 deletions(-) create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/finalizeHydration.ts create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/replayBufferedHydrationOutput.ts create mode 100644 tests/unit/contexts/terminalNode.screen-cache-committed.spec.tsx diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index def81b32..fe6ca435 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -6,6 +6,7 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' import { getPtyEventHub } from '@app/renderer/shell/utils/ptyEventHub' +import { createRollingTextBuffer } from '../utils/rollingTextBuffer' import { createTerminalCommandInputState, parseTerminalCommandInput, @@ -19,19 +20,20 @@ import { setCachedTerminalScreenState, } from './terminalNode/screenStateCache' import { syncTerminalNodeSize } from './terminalNode/syncTerminalNodeSize' -import { resolveSuffixPrefixOverlap } from './terminalNode/overlap' import { resolveTerminalNodeFrameStyle } from './terminalNode/nodeFrameStyle' import { resolveTerminalTheme, resolveTerminalUiTheme } from './terminalNode/theme' import { registerTerminalSelectionTestHandle } from './terminalNode/testHarness' import { patchXtermMouseServiceWithRetry } from './terminalNode/patchXtermMouseService' +import { finalizeTerminalHydration } from './terminalNode/finalizeHydration' import { registerTerminalDiagnostics } from './terminalNode/registerDiagnostics' import { useTerminalThemeApplier } from './terminalNode/useTerminalThemeApplier' import { useTerminalBodyClickFallback } from './terminalNode/useTerminalBodyClickFallback' import { useTerminalFind } from './terminalNode/useTerminalFind' import { useTerminalResize } from './terminalNode/useTerminalResize' import { useTerminalScrollback } from './terminalNode/useScrollback' +import { createCommittedScreenStateRecorder } from './terminalNode/committedScreenState' +import { MAX_SCROLLBACK_CHARS } from './terminalNode/constants' import { resolveInitialTerminalDimensions } from './terminalNode/initialDimensions' -import { revealHydratedTerminal } from './terminalNode/revealHydratedTerminal' import { createTerminalOutputScheduler } from './terminalNode/outputScheduler' import { hydrateTerminalFromSnapshot } from './terminalNode/hydrateFromSnapshot' import { @@ -157,6 +159,10 @@ export function TerminalNode({ const cachedScreenState = getCachedTerminalScreenState(nodeId, sessionId) const initialDimensions = resolveInitialTerminalDimensions(cachedScreenState) const scrollbackBuffer = scrollbackBufferRef.current + const committedScrollbackBuffer = createRollingTextBuffer({ + maxChars: MAX_SCROLLBACK_CHARS, + initial: scrollbackBuffer.snapshot(), + }) const initialTerminalTheme = resolveTerminalTheme(terminalThemeMode) const resolvedTerminalUiTheme = resolveTerminalUiTheme(terminalThemeMode) const windowsPty = window.opencoveApi.meta?.windowsPty ?? null @@ -266,11 +272,20 @@ export function TerminalNode({ const bufferedDataChunks: string[] = [] let bufferedExitCode: number | null = null const ptyEventHub = getPtyEventHub() + const committedScreenStateRecorder = createCommittedScreenStateRecorder({ + serializeAddon, + sessionId, + terminal, + }) const outputScheduler = createTerminalOutputScheduler({ terminal, scrollbackBuffer, markScrollbackDirty, + onWriteCommitted: data => { + committedScrollbackBuffer.append(data) + committedScreenStateRecorder.record(committedScrollbackBuffer.snapshot()) + }, }) outputSchedulerRef.current = outputScheduler outputScheduler.onViewportInteractionActiveChange(isViewportInteractionActiveRef.current) @@ -297,45 +312,32 @@ export function TerminalNode({ const attachPromise = Promise.resolve(ptyWithOptionalAttach.attach?.({ sessionId })) const finalizeHydration = (rawSnapshot: string): void => { - if (isDisposed) { - return - } - - scrollbackBuffer.set(rawSnapshot) isHydrating = false - ptyWriteQueue.flush() - - const bufferedData = bufferedDataChunks.join('') - bufferedDataChunks.length = 0 - - if (bufferedData.length > 0) { - const overlap = resolveSuffixPrefixOverlap(rawSnapshot, bufferedData) - const remainder = bufferedData.slice(overlap) - - if (remainder.length > 0) { - terminal.write(remainder) - scrollbackBuffer.append(remainder) - } - } - - if (bufferedExitCode !== null) { - const exitMessage = `\r\n[process exited with code ${bufferedExitCode}]\r\n` - bufferedExitCode = null - terminal.write(exitMessage) - scrollbackBuffer.append(exitMessage) - } - - markScrollbackDirty(true) - terminalDiagnostics.logHydrated({ - rawSnapshotLength: rawSnapshot.length, + finalizeTerminalHydration({ + isDisposed: () => isDisposed, + rawSnapshot, + scrollbackBuffer, + ptyWriteQueue, + bufferedDataChunks, bufferedExitCode, + terminal, + committedScrollbackBuffer, + onCommittedScreenState: nextRawSnapshot => { + committedScreenStateRecorder.record(nextRawSnapshot) + }, + markScrollbackDirty, + logHydrated: details => { + terminalDiagnostics.logHydrated(details) + }, + syncTerminalSize, + onRevealed: () => { + if (!isDisposed) { + isTerminalHydratedRef.current = true + setIsTerminalHydrated(true) + } + }, }) - revealHydratedTerminal(syncTerminalSize, () => { - if (!isDisposed) { - isTerminalHydratedRef.current = true - setIsTerminalHydrated(true) - } - }) + bufferedExitCode = null } void hydrateTerminalFromSnapshot({ @@ -346,6 +348,10 @@ export function TerminalNode({ persistedSnapshot: scrollbackBuffer.snapshot(), takePtySnapshot: payload => window.opencoveApi.pty.snapshot(payload), isDisposed: () => isDisposed, + onHydratedWriteCommitted: rawSnapshot => { + committedScrollbackBuffer.set(rawSnapshot) + committedScreenStateRecorder.record(rawSnapshot) + }, finalizeHydration: rawSnapshot => { shouldForwardTerminalData = true finalizeHydration(rawSnapshot) @@ -376,15 +382,16 @@ export function TerminalNode({ const hasPendingWrites = outputScheduler.hasPendingWrites() if (!isInvalidated && isTerminalHydratedRef.current && !hasPendingWrites) { - // Live PTY output owns terminal modes; the renderer cache should only restore pixels. - const serializedScreen = serializeAddon.serialize({ excludeModes: true }) - if (serializedScreen.length > 0) { + const latestCommittedScreenState = committedScreenStateRecorder.resolve( + scrollbackBuffer.snapshot(), + ) + if (latestCommittedScreenState) { setCachedTerminalScreenState(nodeId, { - sessionId, - serialized: serializedScreen, - rawSnapshot: scrollbackBuffer.snapshot(), - cols: terminal.cols, - rows: terminal.rows, + sessionId: latestCommittedScreenState.sessionId, + serialized: latestCommittedScreenState.serialized, + rawSnapshot: latestCommittedScreenState.rawSnapshot, + cols: latestCommittedScreenState.cols, + rows: latestCommittedScreenState.rows, }) } } diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts new file mode 100644 index 00000000..12f9af0c --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts @@ -0,0 +1,117 @@ +import type { SerializeAddon } from '@xterm/addon-serialize' +import type { Terminal } from '@xterm/xterm' + +export interface CommittedTerminalScreenState { + sessionId: string + serialized: string + rawSnapshot: string + cols: number + rows: number +} + +export function captureCommittedTerminalScreenState({ + serializeAddon, + sessionId, + rawSnapshot, + terminal, +}: { + serializeAddon: SerializeAddon + sessionId: string + rawSnapshot: string + terminal: Terminal +}): CommittedTerminalScreenState | null { + const serializedScreen = serializeAddon.serialize({ excludeModes: true }) + if (serializedScreen.length === 0) { + return null + } + + return { + sessionId, + serialized: serializedScreen, + rawSnapshot, + cols: terminal.cols, + rows: terminal.rows, + } +} + +export function writeTerminalChunkAndCapture({ + terminal, + data, + committedScrollbackBuffer, + onCommittedScreenState, +}: { + terminal: Terminal + data: string + committedScrollbackBuffer: { + append: (data: string) => void + snapshot: () => string + } + onCommittedScreenState: (rawSnapshot: string) => void +}): void { + terminal.write(data, () => { + committedScrollbackBuffer.append(data) + onCommittedScreenState(committedScrollbackBuffer.snapshot()) + }) +} + +export function resolveCommittedScreenStateForCache({ + latestCommittedScreenState, + serializeAddon, + sessionId, + rawSnapshot, + terminal, +}: { + latestCommittedScreenState: CommittedTerminalScreenState | null + serializeAddon: SerializeAddon + sessionId: string + rawSnapshot: string + terminal: Terminal +}): CommittedTerminalScreenState | null { + return ( + latestCommittedScreenState ?? + captureCommittedTerminalScreenState({ + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) + ) +} + +export function createCommittedScreenStateRecorder({ + serializeAddon, + sessionId, + terminal, +}: { + serializeAddon: SerializeAddon + sessionId: string + terminal: Terminal +}): { + record: (rawSnapshot: string) => void + resolve: (rawSnapshot: string) => CommittedTerminalScreenState | null +} { + let latestCommittedScreenState: CommittedTerminalScreenState | null = null + + return { + record: rawSnapshot => { + latestCommittedScreenState = + captureCommittedTerminalScreenState({ + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) ?? latestCommittedScreenState + }, + resolve: rawSnapshot => { + latestCommittedScreenState = resolveCommittedScreenStateForCache({ + latestCommittedScreenState, + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) + + return latestCommittedScreenState + }, + } +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/finalizeHydration.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/finalizeHydration.ts new file mode 100644 index 00000000..3554ee69 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/finalizeHydration.ts @@ -0,0 +1,66 @@ +import { revealHydratedTerminal } from './revealHydratedTerminal' +import { replayBufferedHydrationOutput } from './replayBufferedHydrationOutput' + +export function finalizeTerminalHydration({ + isDisposed, + rawSnapshot, + scrollbackBuffer, + ptyWriteQueue, + bufferedDataChunks, + bufferedExitCode, + terminal, + committedScrollbackBuffer, + onCommittedScreenState, + markScrollbackDirty, + logHydrated, + syncTerminalSize, + onRevealed, +}: { + isDisposed: () => boolean + rawSnapshot: string + scrollbackBuffer: { + set: (snapshot: string) => void + append: (data: string) => void + } + ptyWriteQueue: { + flush: () => void + } + bufferedDataChunks: string[] + bufferedExitCode: number | null + terminal: Parameters[0]['terminal'] + committedScrollbackBuffer: Parameters< + typeof replayBufferedHydrationOutput + >[0]['committedScrollbackBuffer'] + onCommittedScreenState: (rawSnapshot: string) => void + markScrollbackDirty: (immediate?: boolean) => void + logHydrated: (details: { rawSnapshotLength: number; bufferedExitCode: number | null }) => void + syncTerminalSize: () => void + onRevealed: () => void +}): void { + if (isDisposed()) { + return + } + + scrollbackBuffer.set(rawSnapshot) + ptyWriteQueue.flush() + + const bufferedData = bufferedDataChunks.join('') + bufferedDataChunks.length = 0 + + replayBufferedHydrationOutput({ + terminal, + rawSnapshot, + bufferedData, + bufferedExitCode, + scrollbackBuffer, + committedScrollbackBuffer, + onCommittedScreenState, + }) + + markScrollbackDirty(true) + logHydrated({ + rawSnapshotLength: rawSnapshot.length, + bufferedExitCode, + }) + revealHydratedTerminal(syncTerminalSize, onRevealed) +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts index 042b2f6c..4bfbde70 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts @@ -10,6 +10,7 @@ export async function hydrateTerminalFromSnapshot({ persistedSnapshot, takePtySnapshot, isDisposed, + onHydratedWriteCommitted, finalizeHydration, }: { attachPromise: Promise @@ -19,6 +20,7 @@ export async function hydrateTerminalFromSnapshot({ persistedSnapshot: string takePtySnapshot: (payload: { sessionId: string }) => Promise<{ data: string }> isDisposed: () => boolean + onHydratedWriteCommitted: (rawSnapshot: string) => void finalizeHydration: (rawSnapshot: string) => void }): Promise { await attachPromise.catch(() => undefined) @@ -51,6 +53,7 @@ export async function hydrateTerminalFromSnapshot({ if (restoredPayload.length > 0) { terminal.write(restoredPayload, () => { + onHydratedWriteCommitted(rawSnapshot) finalizeHydration(rawSnapshot) }) return diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts index 5e0d5da5..c3f45a4c 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts @@ -20,11 +20,13 @@ export function createTerminalOutputScheduler({ terminal, scrollbackBuffer, markScrollbackDirty, + onWriteCommitted, options, }: { terminal: Terminal scrollbackBuffer: ScrollbackBuffer markScrollbackDirty: (immediate?: boolean) => void + onWriteCommitted?: (data: string) => void options?: Partial<{ maxPendingChars: number normalWriteChunkChars: number @@ -181,6 +183,7 @@ export function createTerminalOutputScheduler({ remainingBudget -= chunk.length terminal.write(chunk, () => { + onWriteCommitted?.(chunk) pendingWriteFrame = window.requestAnimationFrame(() => { pendingWriteFrame = null drainStep() @@ -217,7 +220,9 @@ export function createTerminalOutputScheduler({ return } - terminal.write(data) + terminal.write(data, () => { + onWriteCommitted?.(data) + }) } const onViewportInteractionActiveChange = (isActive: boolean) => { diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/replayBufferedHydrationOutput.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/replayBufferedHydrationOutput.ts new file mode 100644 index 00000000..9d39af15 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/replayBufferedHydrationOutput.ts @@ -0,0 +1,52 @@ +import type { Terminal } from '@xterm/xterm' +import { resolveSuffixPrefixOverlap } from './overlap' +import { writeTerminalChunkAndCapture } from './committedScreenState' + +export function replayBufferedHydrationOutput({ + terminal, + rawSnapshot, + bufferedData, + bufferedExitCode, + scrollbackBuffer, + committedScrollbackBuffer, + onCommittedScreenState, +}: { + terminal: Terminal + rawSnapshot: string + bufferedData: string + bufferedExitCode: number | null + scrollbackBuffer: { + append: (data: string) => void + } + committedScrollbackBuffer: { + append: (data: string) => void + snapshot: () => string + } + onCommittedScreenState: (rawSnapshot: string) => void +}): void { + if (bufferedData.length > 0) { + const overlap = resolveSuffixPrefixOverlap(rawSnapshot, bufferedData) + const remainder = bufferedData.slice(overlap) + + if (remainder.length > 0) { + writeTerminalChunkAndCapture({ + terminal, + data: remainder, + committedScrollbackBuffer, + onCommittedScreenState, + }) + scrollbackBuffer.append(remainder) + } + } + + if (bufferedExitCode !== null) { + const exitMessage = `\r\n[process exited with code ${bufferedExitCode}]\r\n` + writeTerminalChunkAndCapture({ + terminal, + data: exitMessage, + committedScrollbackBuffer, + onCommittedScreenState, + }) + scrollbackBuffer.append(exitMessage) + } +} diff --git a/tests/unit/contexts/terminalNode.screen-cache-committed.spec.tsx b/tests/unit/contexts/terminalNode.screen-cache-committed.spec.tsx new file mode 100644 index 00000000..d1da57c4 --- /dev/null +++ b/tests/unit/contexts/terminalNode.screen-cache-committed.spec.tsx @@ -0,0 +1,184 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearCachedTerminalScreenStates, + getCachedTerminalScreenState, +} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache' + +type DataEvent = { sessionId: string; data: string } +type ExitEvent = { sessionId: string; exitCode: number } + +declare global { + interface Window { + ResizeObserver: typeof ResizeObserver + } +} + +const serializeSpy = vi.fn(() => '[initial-screen]') + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + public cols = 80 + public rows = 24 + public options = { fontSize: 13 } + + public constructor(options?: { cols?: number; rows?: number }) { + this.cols = options?.cols ?? 80 + this.rows = options?.rows ?? 24 + } + + public loadAddon(addon: { activate?: (terminal: MockTerminal) => void }): void { + addon.activate?.(this) + } + + public open(): void {} + public focus(): void {} + public refresh(): void {} + public dispose(): void {} + public attachCustomKeyEventHandler(): void {} + + public onData() { + return { dispose: () => undefined } + } + + public onBinary() { + return { dispose: () => undefined } + } + + public write(_data: string, callback?: () => void): void { + callback?.() + } + } + + return { + Terminal: MockTerminal, + } +}) + +vi.mock('@xterm/addon-fit', () => { + class MockFitAddon { + public fit(): void {} + } + + return { FitAddon: MockFitAddon } +}) + +vi.mock('@xterm/addon-serialize', () => { + class MockSerializeAddon { + public activate(): void {} + + public serialize(options?: unknown): string { + return serializeSpy(options) + } + + public dispose(): void {} + } + + return { SerializeAddon: MockSerializeAddon } +}) + +vi.mock('@xyflow/react', () => { + return { + Handle: () => null, + Position: { + Left: 'left', + Right: 'right', + }, + useStore: (selector: (state: unknown) => unknown) => + selector({ coveDragSurfaceSelectionMode: false }), + } +}) + +describe('TerminalNode committed screen cache', () => { + beforeEach(() => { + clearCachedTerminalScreenStates() + serializeSpy.mockClear() + serializeSpy.mockReturnValue('[initial-screen]') + + if (typeof window.ResizeObserver === 'undefined') { + window.ResizeObserver = class ResizeObserver { + public observe(): void {} + public disconnect(): void {} + public unobserve(): void {} + } + } + + document.documentElement.dataset.coveTheme = 'dark' + document.documentElement.style.setProperty('--cove-terminal-background', '#0a0f1d') + document.documentElement.style.setProperty('--cove-terminal-foreground', '#d6e4ff') + document.documentElement.style.setProperty('--cove-terminal-cursor', '#d6e4ff') + document.documentElement.style.setProperty( + '--cove-terminal-selection', + 'rgba(94, 156, 255, 0.35)', + ) + }) + + it('reuses the latest committed screen cache when unmount-time serialize is stale', async () => { + let dataListener: ((event: DataEvent) => void) | null = null + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + meta: { + isTest: true, + }, + pty: { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + snapshot: vi.fn(async () => ({ data: 'BOOT' })), + onData: vi.fn((listener: (event: DataEvent) => void) => { + dataListener = listener + return () => undefined + }), + onExit: vi.fn((_listener: (event: ExitEvent) => void) => () => undefined), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }, + }) + + const { TerminalNode } = + await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') + + const view = render( + undefined} + onResize={() => undefined} + />, + ) + + await waitFor(() => { + expect(window.opencoveApi.pty.snapshot).toHaveBeenCalledTimes(1) + }) + + serializeSpy.mockReturnValue('SCREEN_AFTER_LIVE_WRITE') + dataListener?.({ sessionId: 'session-cache-committed', data: 'LIVE_FRAME' }) + + await waitFor(() => { + expect(serializeSpy).toHaveBeenCalledWith({ excludeModes: true }) + }) + + serializeSpy.mockReturnValue('STALE_UNMOUNT_SCREEN') + view.unmount() + + expect(getCachedTerminalScreenState('node-cache-committed', 'session-cache-committed')).toEqual( + expect.objectContaining({ + serialized: 'SCREEN_AFTER_LIVE_WRITE', + rawSnapshot: 'BOOTLIVE_FRAME', + }), + ) + }) +}) From 04007ac964c6ebb5832fd3ef0797d3329e33f63b Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 01:20:36 +0800 Subject: [PATCH 10/22] fix: keep committed terminal cache during pending writes --- .../renderer/components/TerminalNode.tsx | 6 +- .../terminalNode/committedScreenState.ts | 24 ++- ...lNode.screen-cache.pending-writes.spec.tsx | 195 ++++++++++++++++++ 3 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index fe6ca435..efe13072 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -295,7 +295,6 @@ export function TerminalNode({ bufferedDataChunks.push(event.data) return } - outputScheduler.handleChunk(event.data) }) @@ -381,9 +380,12 @@ export function TerminalNode({ const hasPendingWrites = outputScheduler.hasPendingWrites() - if (!isInvalidated && isTerminalHydratedRef.current && !hasPendingWrites) { + if (!isInvalidated && isTerminalHydratedRef.current) { const latestCommittedScreenState = committedScreenStateRecorder.resolve( scrollbackBuffer.snapshot(), + { + allowSerializeFallback: !hasPendingWrites, + }, ) if (latestCommittedScreenState) { setCachedTerminalScreenState(nodeId, { diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts index 12f9af0c..197958d8 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts @@ -88,7 +88,10 @@ export function createCommittedScreenStateRecorder({ terminal: Terminal }): { record: (rawSnapshot: string) => void - resolve: (rawSnapshot: string) => CommittedTerminalScreenState | null + resolve: ( + rawSnapshot: string, + options?: { allowSerializeFallback?: boolean }, + ) => CommittedTerminalScreenState | null } { let latestCommittedScreenState: CommittedTerminalScreenState | null = null @@ -102,14 +105,17 @@ export function createCommittedScreenStateRecorder({ terminal, }) ?? latestCommittedScreenState }, - resolve: rawSnapshot => { - latestCommittedScreenState = resolveCommittedScreenStateForCache({ - latestCommittedScreenState, - serializeAddon, - sessionId, - rawSnapshot, - terminal, - }) + resolve: (rawSnapshot, options) => { + const allowSerializeFallback = options?.allowSerializeFallback !== false + if (latestCommittedScreenState || allowSerializeFallback) { + latestCommittedScreenState = resolveCommittedScreenStateForCache({ + latestCommittedScreenState, + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) + } return latestCommittedScreenState }, diff --git a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx new file mode 100644 index 00000000..2e6a8afc --- /dev/null +++ b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx @@ -0,0 +1,195 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearCachedTerminalScreenStates, + getCachedTerminalScreenState, +} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache' + +type DataEvent = { sessionId: string; data: string } +type ExitEvent = { sessionId: string; exitCode: number } + +declare global { + interface Window { + ResizeObserver: typeof ResizeObserver + } +} + +const serializeSpy = vi.fn(() => '[initial-screen]') + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + public cols = 80 + public rows = 24 + public options = { fontSize: 13 } + + public loadAddon(addon: { activate?: (terminal: MockTerminal) => void }): void { + addon.activate?.(this) + } + + public open(): void {} + public focus(): void {} + public refresh(): void {} + public dispose(): void {} + public attachCustomKeyEventHandler(): void {} + + public onData() { + return { dispose: () => undefined } + } + + public onBinary() { + return { dispose: () => undefined } + } + + public write(_data: string, callback?: () => void): void { + callback?.() + } + } + + return { + Terminal: MockTerminal, + } +}) + +vi.mock('@xterm/addon-fit', () => { + class MockFitAddon { + public fit(): void {} + } + + return { FitAddon: MockFitAddon } +}) + +vi.mock('@xterm/addon-serialize', () => { + class MockSerializeAddon { + public activate(): void {} + + public serialize(options?: unknown): string { + return serializeSpy(options) + } + + public dispose(): void {} + } + + return { SerializeAddon: MockSerializeAddon } +}) + +vi.mock('@xyflow/react', () => { + return { + Handle: () => null, + Position: { + Left: 'left', + Right: 'right', + }, + useStore: (selector: (state: unknown) => unknown) => + selector({ coveDragSurfaceSelectionMode: false }), + } +}) + +vi.mock( + '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler', + async () => { + const actual = await vi.importActual< + typeof import('../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler') + >( + '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler', + ) + + return { + ...actual, + createTerminalOutputScheduler: () => ({ + handleChunk: () => undefined, + onViewportInteractionActiveChange: () => undefined, + hasPendingWrites: () => true, + dispose: () => undefined, + }), + } + }, +) + +describe('TerminalNode screen cache with pending writes', () => { + beforeEach(() => { + clearCachedTerminalScreenStates() + serializeSpy.mockClear() + serializeSpy.mockReturnValue('SCREEN_AFTER_HYDRATION') + + if (typeof window.ResizeObserver === 'undefined') { + window.ResizeObserver = class ResizeObserver { + public observe(): void {} + public disconnect(): void {} + public unobserve(): void {} + } + } + + document.documentElement.dataset.coveTheme = 'dark' + document.documentElement.style.setProperty('--cove-terminal-background', '#0a0f1d') + document.documentElement.style.setProperty('--cove-terminal-foreground', '#d6e4ff') + document.documentElement.style.setProperty('--cove-terminal-cursor', '#d6e4ff') + document.documentElement.style.setProperty( + '--cove-terminal-selection', + 'rgba(94, 156, 255, 0.35)', + ) + }) + + it('preserves the latest committed cache even when pending writes remain on unmount', async () => { + let dataListener: ((event: DataEvent) => void) | null = null + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + writable: true, + value: { + meta: { + isTest: true, + }, + pty: { + attach: vi.fn(async () => undefined), + detach: vi.fn(async () => undefined), + snapshot: vi.fn(async () => ({ data: 'BOOT' })), + onData: vi.fn((listener: (event: DataEvent) => void) => { + dataListener = listener + return () => undefined + }), + onExit: vi.fn((_listener: (event: ExitEvent) => void) => () => undefined), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }, + }) + + const { TerminalNode } = + await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') + + const view = render( + undefined} + onResize={() => undefined} + />, + ) + + await waitFor(() => { + expect(window.opencoveApi.pty.snapshot).toHaveBeenCalledTimes(1) + }) + + dataListener?.({ sessionId: 'session-cache-pending', data: 'LIVE_FRAME' }) + + serializeSpy.mockReturnValue('STALE_UNMOUNT_SCREEN') + view.unmount() + + expect(getCachedTerminalScreenState('node-cache-pending', 'session-cache-pending')).toEqual( + expect.objectContaining({ + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + }), + ) + }) +}) From c5a55aefe9894cd0200c610dbd7004bfe71b5933 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 11:20:17 +0800 Subject: [PATCH 11/22] fix: drop stale terminal screen cache on pending writes --- .../terminalNode/cacheTerminalScreenState.ts | 7 +- .../terminalNode/screenStateCache.ts | 18 ++ ...alNode.screen-cache.pending-writes.spec.ts | 73 +++++++ ...lNode.screen-cache.pending-writes.spec.tsx | 195 ------------------ 4 files changed, 97 insertions(+), 196 deletions(-) create mode 100644 tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts delete mode 100644 tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts index 7709b113..0ccef575 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts @@ -1,5 +1,5 @@ import type { CommittedTerminalScreenState } from './committedScreenState' -import { setCachedTerminalScreenState } from './screenStateCache' +import { removeCachedTerminalScreenState, setCachedTerminalScreenState } from './screenStateCache' export function cacheTerminalScreenStateOnUnmount({ nodeId, @@ -30,6 +30,11 @@ export function cacheTerminalScreenStateOnUnmount({ return } + if (hasPendingWrites && latestCommittedScreenState.rawSnapshot !== rawSnapshot) { + removeCachedTerminalScreenState(nodeId, latestCommittedScreenState.sessionId) + return + } + setCachedTerminalScreenState(nodeId, { sessionId: latestCommittedScreenState.sessionId, serialized: latestCommittedScreenState.serialized, diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts index 68dfedba..6fe0bf02 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts @@ -72,6 +72,24 @@ export function setCachedTerminalScreenState( }) } +export function removeCachedTerminalScreenState(nodeId: string, sessionId: string): void { + const normalizedNodeId = normalizeId(nodeId) + const normalizedSessionId = normalizeId(sessionId) + + if (normalizedNodeId.length === 0 || normalizedSessionId.length === 0) { + return + } + + const cached = screenStateByNodeId.get(normalizedNodeId) + if (cached?.sessionId === normalizedSessionId) { + screenStateByNodeId.delete(normalizedNodeId) + } + + if (invalidatedSessionIdByNodeId.get(normalizedNodeId) === normalizedSessionId) { + invalidatedSessionIdByNodeId.delete(normalizedNodeId) + } +} + export function invalidateCachedTerminalScreenState(nodeId: string, sessionId: string): void { const normalizedNodeId = normalizeId(nodeId) const normalizedSessionId = normalizeId(sessionId) diff --git a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts new file mode 100644 index 00000000..e487125f --- /dev/null +++ b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { cacheTerminalScreenStateOnUnmount } from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState' +import { + clearCachedTerminalScreenStates, + getCachedTerminalScreenState, + setCachedTerminalScreenState, +} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache' + +describe('TerminalNode screen cache with pending writes', () => { + beforeEach(() => { + clearCachedTerminalScreenStates() + }) + + it('drops stale committed cache when pending writes have advanced the raw snapshot', () => { + setCachedTerminalScreenState('node-cache-pending', { + sessionId: 'session-cache-pending', + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + cols: 80, + rows: 24, + }) + + const resolveCommittedScreenState = vi.fn(() => ({ + sessionId: 'session-cache-pending', + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + cols: 80, + rows: 24, + })) + + cacheTerminalScreenStateOnUnmount({ + nodeId: 'node-cache-pending', + isInvalidated: false, + isTerminalHydrated: true, + hasPendingWrites: true, + rawSnapshot: 'BOOTLIVE_FRAME', + resolveCommittedScreenState, + }) + + expect(resolveCommittedScreenState).toHaveBeenCalledWith('BOOTLIVE_FRAME', { + allowSerializeFallback: false, + }) + expect(getCachedTerminalScreenState('node-cache-pending', 'session-cache-pending')).toBeNull() + }) + + it('keeps the committed cache when pending writes have not advanced past the committed raw snapshot', () => { + const resolveCommittedScreenState = vi.fn(() => ({ + sessionId: 'session-cache-pending-stable', + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + cols: 80, + rows: 24, + })) + + cacheTerminalScreenStateOnUnmount({ + nodeId: 'node-cache-pending-stable', + isInvalidated: false, + isTerminalHydrated: true, + hasPendingWrites: true, + rawSnapshot: 'BOOT', + resolveCommittedScreenState, + }) + + expect( + getCachedTerminalScreenState('node-cache-pending-stable', 'session-cache-pending-stable'), + ).toEqual( + expect.objectContaining({ + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + }), + ) + }) +}) diff --git a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx deleted file mode 100644 index 2e6a8afc..00000000 --- a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from 'react' -import { render, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - clearCachedTerminalScreenStates, - getCachedTerminalScreenState, -} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache' - -type DataEvent = { sessionId: string; data: string } -type ExitEvent = { sessionId: string; exitCode: number } - -declare global { - interface Window { - ResizeObserver: typeof ResizeObserver - } -} - -const serializeSpy = vi.fn(() => '[initial-screen]') - -vi.mock('@xterm/xterm', () => { - class MockTerminal { - public cols = 80 - public rows = 24 - public options = { fontSize: 13 } - - public loadAddon(addon: { activate?: (terminal: MockTerminal) => void }): void { - addon.activate?.(this) - } - - public open(): void {} - public focus(): void {} - public refresh(): void {} - public dispose(): void {} - public attachCustomKeyEventHandler(): void {} - - public onData() { - return { dispose: () => undefined } - } - - public onBinary() { - return { dispose: () => undefined } - } - - public write(_data: string, callback?: () => void): void { - callback?.() - } - } - - return { - Terminal: MockTerminal, - } -}) - -vi.mock('@xterm/addon-fit', () => { - class MockFitAddon { - public fit(): void {} - } - - return { FitAddon: MockFitAddon } -}) - -vi.mock('@xterm/addon-serialize', () => { - class MockSerializeAddon { - public activate(): void {} - - public serialize(options?: unknown): string { - return serializeSpy(options) - } - - public dispose(): void {} - } - - return { SerializeAddon: MockSerializeAddon } -}) - -vi.mock('@xyflow/react', () => { - return { - Handle: () => null, - Position: { - Left: 'left', - Right: 'right', - }, - useStore: (selector: (state: unknown) => unknown) => - selector({ coveDragSurfaceSelectionMode: false }), - } -}) - -vi.mock( - '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler', - async () => { - const actual = await vi.importActual< - typeof import('../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler') - >( - '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler', - ) - - return { - ...actual, - createTerminalOutputScheduler: () => ({ - handleChunk: () => undefined, - onViewportInteractionActiveChange: () => undefined, - hasPendingWrites: () => true, - dispose: () => undefined, - }), - } - }, -) - -describe('TerminalNode screen cache with pending writes', () => { - beforeEach(() => { - clearCachedTerminalScreenStates() - serializeSpy.mockClear() - serializeSpy.mockReturnValue('SCREEN_AFTER_HYDRATION') - - if (typeof window.ResizeObserver === 'undefined') { - window.ResizeObserver = class ResizeObserver { - public observe(): void {} - public disconnect(): void {} - public unobserve(): void {} - } - } - - document.documentElement.dataset.coveTheme = 'dark' - document.documentElement.style.setProperty('--cove-terminal-background', '#0a0f1d') - document.documentElement.style.setProperty('--cove-terminal-foreground', '#d6e4ff') - document.documentElement.style.setProperty('--cove-terminal-cursor', '#d6e4ff') - document.documentElement.style.setProperty( - '--cove-terminal-selection', - 'rgba(94, 156, 255, 0.35)', - ) - }) - - it('preserves the latest committed cache even when pending writes remain on unmount', async () => { - let dataListener: ((event: DataEvent) => void) | null = null - - Object.defineProperty(window, 'opencoveApi', { - configurable: true, - writable: true, - value: { - meta: { - isTest: true, - }, - pty: { - attach: vi.fn(async () => undefined), - detach: vi.fn(async () => undefined), - snapshot: vi.fn(async () => ({ data: 'BOOT' })), - onData: vi.fn((listener: (event: DataEvent) => void) => { - dataListener = listener - return () => undefined - }), - onExit: vi.fn((_listener: (event: ExitEvent) => void) => () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - }, - }, - }) - - const { TerminalNode } = - await import('../../../src/contexts/workspace/presentation/renderer/components/TerminalNode') - - const view = render( - undefined} - onResize={() => undefined} - />, - ) - - await waitFor(() => { - expect(window.opencoveApi.pty.snapshot).toHaveBeenCalledTimes(1) - }) - - dataListener?.({ sessionId: 'session-cache-pending', data: 'LIVE_FRAME' }) - - serializeSpy.mockReturnValue('STALE_UNMOUNT_SCREEN') - view.unmount() - - expect(getCachedTerminalScreenState('node-cache-pending', 'session-cache-pending')).toEqual( - expect.objectContaining({ - serialized: 'SCREEN_AFTER_HYDRATION', - rawSnapshot: 'BOOT', - }), - ) - }) -}) From 7bc98169df105a536d4cbaa579b0985ef1dcc69d Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 11:45:49 +0800 Subject: [PATCH 12/22] fix: refresh terminal screen cache after buffer switch --- .../terminalNode/committedScreenState.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts index 197958d8..d17b9d91 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts @@ -1,12 +1,27 @@ import type { SerializeAddon } from '@xterm/addon-serialize' import type { Terminal } from '@xterm/xterm' +type TerminalBufferKind = 'normal' | 'alternate' | 'unknown' + export interface CommittedTerminalScreenState { sessionId: string serialized: string rawSnapshot: string cols: number rows: number + bufferKind: TerminalBufferKind +} + +function resolveTerminalBufferKind(terminal: Terminal): TerminalBufferKind { + const buffer = (terminal as unknown as { buffer?: { active?: { type?: unknown } } }).buffer + const type = buffer?.active?.type + if (type === 'alternate') { + return 'alternate' + } + if (type === 'normal') { + return 'normal' + } + return 'unknown' } export function captureCommittedTerminalScreenState({ @@ -31,6 +46,7 @@ export function captureCommittedTerminalScreenState({ rawSnapshot, cols: terminal.cols, rows: terminal.rows, + bufferKind: resolveTerminalBufferKind(terminal), } } @@ -107,7 +123,21 @@ export function createCommittedScreenStateRecorder({ }, resolve: (rawSnapshot, options) => { const allowSerializeFallback = options?.allowSerializeFallback !== false - if (latestCommittedScreenState || allowSerializeFallback) { + const currentBufferKind = resolveTerminalBufferKind(terminal) + const shouldRefreshForBufferSwitch = + allowSerializeFallback && + currentBufferKind !== 'unknown' && + latestCommittedScreenState?.bufferKind !== currentBufferKind + + if (shouldRefreshForBufferSwitch) { + latestCommittedScreenState = + captureCommittedTerminalScreenState({ + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) ?? latestCommittedScreenState + } else if (latestCommittedScreenState || allowSerializeFallback) { latestCommittedScreenState = resolveCommittedScreenStateForCache({ latestCommittedScreenState, serializeAddon, From 73316331a550c709eb2df49cf175179f91f88809 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 12:42:44 +0800 Subject: [PATCH 13/22] fix: keep terminal screen cache during pending writes --- docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md | 82 +++++++++++++++++++ .../terminalNode/cacheTerminalScreenState.ts | 7 +- ...alNode.screen-cache.pending-writes.spec.ts | 9 +- 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md diff --git a/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md new file mode 100644 index 00000000..bf1d4eae --- /dev/null +++ b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md @@ -0,0 +1,82 @@ +# Terminal ANSI Screen Persistence (Workspace Switch) + +Date: 2026-03-30 +Scope: renderer xterm persistence for full-screen TUI / alternate-screen content when switching workspaces. + +## Symptom + +Ubuntu CI consistently fails the E2E: + +- `tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts` +- Assertion fails after a workspace switch: + - expected: terminal contains `FRAME_29999_TOKEN` + - actual: terminal often only shows `ROW_*_STATIC` + prompt, but not the final `FRAME_*` line + +## Why This Is Tricky + +This test intentionally produces a large amount of output: + +- Enters alternate screen (`ESC[?1049h`) +- Draws static rows using absolute cursor positioning +- Writes 30,000 frames to the same absolute row (`ESC[20;1H...`) + +OpenCove maintains a PTY snapshot and a persisted scrollback snapshot, but both are capped: + +- cap: `400_000` chars (see `src/platform/process/pty/snapshot.ts` and terminal scrollback constants) + +When output exceeds the cap: + +- raw snapshots skew toward the most recent data (tail) +- the initial "enter alt screen" sequence and early static draw can fall out of the snapshot window + +So restoring from raw snapshot alone can lose the "full-screen" semantics. This is why OpenCove also +caches an xterm SerializeAddon-based "committed screen state" on unmount. + +## Restore Pipeline (Current) + +1. On unmount: + - cache `{ serializedScreen, rawSnapshotBase, cols, rows }` per `nodeId/sessionId` +2. On mount: + - write cached `serializedScreen` + - fetch `pty.snapshot` and append only the delta (computed via suffix/prefix overlap) + - this catches up the screen to the latest PTY state without relying on full raw replay + +## Failure Mode + +During high-volume output, xterm writes are chunked and can still be draining while the user (or E2E) +switches workspaces. + +If we drop the cached committed screen state during that window, the remount path may fall back to +persisted scrollback, which can be: + +- stale (publish is debounced) +- or trimmed (cap) such that the expected final frame token is missing + +## Fix + +Keep the latest committed screen cache even when there are pending writes. + +The cache is allowed to be slightly behind; the remount path will still fetch `pty.snapshot` and +apply the delta to catch up. Deleting the cache entirely is worse because it removes the only +representation that can preserve alternate-screen semantics when the raw snapshot cap is exceeded. + +## Verification + +Local: + +```powershell +pnpm build +$env:OPENCOVE_E2E_WINDOW_MODE='inactive' +pnpm exec playwright test tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts --project electron --reporter=line +``` + +CI: + +- `ci (ubuntu-latest)` should pass the `Workspace Canvas - Persistence ANSI screen restore` E2E. + +## Follow-ups (If We Need Stronger Guarantees) + +- Add bounded "drain pending writes before caching" logic on unmount (avoid UI jank). +- Extend `OPENCOVE_TERMINAL_DIAGNOSTICS=1` to log cache/hydrate decision points (cache hit/miss, + pending writes, raw snapshot lengths, alt/normal buffer kind). + diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts index 0ccef575..7709b113 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts @@ -1,5 +1,5 @@ import type { CommittedTerminalScreenState } from './committedScreenState' -import { removeCachedTerminalScreenState, setCachedTerminalScreenState } from './screenStateCache' +import { setCachedTerminalScreenState } from './screenStateCache' export function cacheTerminalScreenStateOnUnmount({ nodeId, @@ -30,11 +30,6 @@ export function cacheTerminalScreenStateOnUnmount({ return } - if (hasPendingWrites && latestCommittedScreenState.rawSnapshot !== rawSnapshot) { - removeCachedTerminalScreenState(nodeId, latestCommittedScreenState.sessionId) - return - } - setCachedTerminalScreenState(nodeId, { sessionId: latestCommittedScreenState.sessionId, serialized: latestCommittedScreenState.serialized, diff --git a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts index e487125f..b7460e58 100644 --- a/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts +++ b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts @@ -11,7 +11,7 @@ describe('TerminalNode screen cache with pending writes', () => { clearCachedTerminalScreenStates() }) - it('drops stale committed cache when pending writes have advanced the raw snapshot', () => { + it('keeps the committed cache when pending writes have advanced the raw snapshot', () => { setCachedTerminalScreenState('node-cache-pending', { sessionId: 'session-cache-pending', serialized: 'SCREEN_AFTER_HYDRATION', @@ -40,7 +40,12 @@ describe('TerminalNode screen cache with pending writes', () => { expect(resolveCommittedScreenState).toHaveBeenCalledWith('BOOTLIVE_FRAME', { allowSerializeFallback: false, }) - expect(getCachedTerminalScreenState('node-cache-pending', 'session-cache-pending')).toBeNull() + expect(getCachedTerminalScreenState('node-cache-pending', 'session-cache-pending')).toEqual( + expect.objectContaining({ + serialized: 'SCREEN_AFTER_HYDRATION', + rawSnapshot: 'BOOT', + }), + ) }) it('keeps the committed cache when pending writes have not advanced past the committed raw snapshot', () => { From c3c2abeb5075bc00ed0cceae98d67e19bcb4e281 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 13:09:48 +0800 Subject: [PATCH 14/22] fix: sync terminal size before hydration --- .../presentation/renderer/components/TerminalNode.tsx | 2 +- tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index 4ac4fa7e..0d9899d0 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -220,6 +220,7 @@ export function TerminalNode({ if (window.opencoveApi.meta.isTest) { disposeTerminalSelectionTestHandle = registerTerminalSelectionTestHandle(nodeId, terminal) } + syncTerminalSize() requestAnimationFrame(syncTerminalSize) if (window.opencoveApi.meta.isTest) { terminal.focus() @@ -237,7 +238,6 @@ export function TerminalNode({ terminalThemeMode, windowsPty, }) - let isDisposed = false let shouldForwardTerminalData = false const dataDisposable = terminal.onData(data => { diff --git a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts index fa4c0aa7..3ff208d1 100644 --- a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts +++ b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts @@ -8,7 +8,11 @@ import { test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { test('preserves full-screen ANSI content after workspace switch', async () => { - const { electronApp, window } = await launchApp() + const { electronApp, window } = await launchApp({ + env: { + OPENCOVE_TERMINAL_DIAGNOSTICS: '1', + }, + }) try { await seedWorkspaceState(window, { From 1a8347d6ea688c76d0e5c7a0bdea9e09b5ac00ab Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 13:33:09 +0800 Subject: [PATCH 15/22] test: log terminal size in ansi screen restore --- .../components/terminalNode/testHarness.ts | 12 ++++++++++++ ...ace-canvas.persistence.ansi-screen.spec.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts index 75907e3b..a3b2c47d 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts @@ -8,6 +8,7 @@ type TerminalSelectionHandle = Pick< type TerminalSelectionTestApi = { clearSelection: (nodeId: string) => boolean getCellCenter: (nodeId: string, col: number, row: number) => { x: number; y: number } | null + getSize: (nodeId: string) => { cols: number; rows: number } | null emitBinaryInput: (nodeId: string, data: string) => boolean getSelection: (nodeId: string) => string | null hasSelection: (nodeId: string) => boolean @@ -95,6 +96,17 @@ function getTerminalSelectionTestApi(): TerminalSelectionTestApi | undefined { y: rect.top + (topPadding + (clampedRow - 0.5) * cellHeight) * scaleY, } }, + getSize: nodeId => { + const terminal = terminalHandles.get(nodeId) + if (!terminal) { + return null + } + + return { + cols: terminal.cols, + rows: terminal.rows, + } + }, emitBinaryInput: (nodeId, data) => { const terminal = terminalHandles.get(nodeId) as unknown as { _core?: { coreService?: { triggerBinaryEvent?: (payload: string) => void } } diff --git a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts index 3ff208d1..feeb9f2e 100644 --- a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts +++ b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts @@ -53,6 +53,13 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { await expect(terminal).toBeVisible() await expect(terminal.locator('.xterm')).toBeVisible() + const initialSize = await window.evaluate(() => { + return window.__opencoveTerminalSelectionTestApi?.getSize?.('node-a') ?? null + }) + // Useful when debugging linux-only fit/hydration regressions (printed to CI logs on failure). + // eslint-disable-next-line no-console + console.log('[ansi-screen] initial size', initialSize) + const command = buildNodeEvalCommand( [ 'const esc="\\x1b[";', @@ -74,12 +81,24 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { await expect(terminal).toContainText('ROW_10_STATIC', { timeout: 20_000 }) await expect(terminal).toContainText('FRAME_29999_TOKEN', { timeout: 20_000 }) + const beforeSwitchSize = await window.evaluate(() => { + return window.__opencoveTerminalSelectionTestApi?.getSize?.('node-a') ?? null + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] before switch size', beforeSwitchSize) + await window.locator('.workspace-item').nth(1).click() await expect(window.locator('.workspace-item').nth(1)).toHaveClass(/workspace-item--active/) await window.locator('.workspace-item').nth(0).click() await expect(window.locator('.workspace-item').nth(0)).toHaveClass(/workspace-item--active/) + const afterRestoreSize = await window.evaluate(() => { + return window.__opencoveTerminalSelectionTestApi?.getSize?.('node-a') ?? null + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] after restore size', afterRestoreSize) + const restoredTerminal = window.locator('.terminal-node').first() await expect(restoredTerminal).toContainText('FRAME_29999_TOKEN', { timeout: 20_000 }) await expect(restoredTerminal).toContainText('ROW_10_STATIC', { timeout: 20_000 }) From cabedc0cf96b1329d8361a701e97ea4257059674 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 13:50:12 +0800 Subject: [PATCH 16/22] test: log frame visibility around workspace switch --- .../workspace-canvas.persistence.ansi-screen.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts index feeb9f2e..fc0f0e72 100644 --- a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts +++ b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts @@ -86,6 +86,11 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { }) // eslint-disable-next-line no-console console.log('[ansi-screen] before switch size', beforeSwitchSize) + const beforeSwitchHasFrame = await terminal.evaluate(el => { + return el.textContent?.includes('FRAME_29999_TOKEN') ?? false + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] before switch has frame', beforeSwitchHasFrame) await window.locator('.workspace-item').nth(1).click() await expect(window.locator('.workspace-item').nth(1)).toHaveClass(/workspace-item--active/) @@ -100,6 +105,11 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { console.log('[ansi-screen] after restore size', afterRestoreSize) const restoredTerminal = window.locator('.terminal-node').first() + const afterRestoreHasFrame = await restoredTerminal.evaluate(el => { + return el.textContent?.includes('FRAME_29999_TOKEN') ?? false + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] after restore has frame', afterRestoreHasFrame) await expect(restoredTerminal).toContainText('FRAME_29999_TOKEN', { timeout: 20_000 }) await expect(restoredTerminal).toContainText('ROW_10_STATIC', { timeout: 20_000 }) } finally { From 9db6abca101c12aeda59a5771ec7878cab0f7bbc Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 14:11:00 +0800 Subject: [PATCH 17/22] test: log ansi screen cache summary --- .../terminalNode/screenStateCache.ts | 9 ++++++++ .../components/terminalNode/testHarness.ts | 22 +++++++++++++++++++ ...ace-canvas.persistence.ansi-screen.spec.ts | 14 ++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts index 6fe0bf02..98701028 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts @@ -42,6 +42,15 @@ export function getCachedTerminalScreenState( return cached } +export function peekCachedTerminalScreenState(nodeId: string): CachedTerminalScreenState | null { + const normalizedNodeId = normalizeId(nodeId) + if (normalizedNodeId.length === 0) { + return null + } + + return screenStateByNodeId.get(normalizedNodeId) ?? null +} + export function setCachedTerminalScreenState( nodeId: string, state: CachedTerminalScreenState, diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts index a3b2c47d..b1388010 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { peekCachedTerminalScreenState } from './screenStateCache' type TerminalSelectionHandle = Pick< Terminal, @@ -9,6 +10,13 @@ type TerminalSelectionTestApi = { clearSelection: (nodeId: string) => boolean getCellCenter: (nodeId: string, col: number, row: number) => { x: number; y: number } | null getSize: (nodeId: string) => { cols: number; rows: number } | null + getCachedScreenStateSummary: (nodeId: string) => { + sessionId: string + serializedLength: number + rawSnapshotLength: number + serializedHasFrameToken: boolean + rawSnapshotHasFrameToken: boolean + } | null emitBinaryInput: (nodeId: string, data: string) => boolean getSelection: (nodeId: string) => string | null hasSelection: (nodeId: string) => boolean @@ -107,6 +115,20 @@ function getTerminalSelectionTestApi(): TerminalSelectionTestApi | undefined { rows: terminal.rows, } }, + getCachedScreenStateSummary: nodeId => { + const cached = peekCachedTerminalScreenState(nodeId) + if (!cached) { + return null + } + + return { + sessionId: cached.sessionId, + serializedLength: cached.serialized.length, + rawSnapshotLength: cached.rawSnapshot.length, + serializedHasFrameToken: cached.serialized.includes('FRAME_29999_TOKEN'), + rawSnapshotHasFrameToken: cached.rawSnapshot.includes('FRAME_29999_TOKEN'), + } + }, emitBinaryInput: (nodeId, data) => { const terminal = terminalHandles.get(nodeId) as unknown as { _core?: { coreService?: { triggerBinaryEvent?: (payload: string) => void } } diff --git a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts index fc0f0e72..37184490 100644 --- a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts +++ b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts @@ -94,6 +94,13 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { await window.locator('.workspace-item').nth(1).click() await expect(window.locator('.workspace-item').nth(1)).toHaveClass(/workspace-item--active/) + const afterUnmountCache = await window.evaluate(() => { + return ( + window.__opencoveTerminalSelectionTestApi?.getCachedScreenStateSummary?.('node-a') ?? null + ) + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] after unmount cache', afterUnmountCache) await window.locator('.workspace-item').nth(0).click() await expect(window.locator('.workspace-item').nth(0)).toHaveClass(/workspace-item--active/) @@ -103,6 +110,13 @@ test.describe('Workspace Canvas - Persistence ANSI screen restore', () => { }) // eslint-disable-next-line no-console console.log('[ansi-screen] after restore size', afterRestoreSize) + const afterRestoreCache = await window.evaluate(() => { + return ( + window.__opencoveTerminalSelectionTestApi?.getCachedScreenStateSummary?.('node-a') ?? null + ) + }) + // eslint-disable-next-line no-console + console.log('[ansi-screen] after restore cache', afterRestoreCache) const restoredTerminal = window.locator('.terminal-node').first() const afterRestoreHasFrame = await restoredTerminal.evaluate(el => { From c59b49e63699fff36ccae3f8b17ffacc6ad560e3 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 15:25:35 +0800 Subject: [PATCH 18/22] fix: preserve alt-screen restore against pty delta --- .../renderer/components/TerminalNode.tsx | 30 +++++++++---------- .../terminalNode/hydrateFromSnapshot.ts | 15 +++++++++- .../terminalNode/syncTerminalNodeSize.ts | 6 ++++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index 0d9899d0..ea837a81 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -80,6 +80,7 @@ export function TerminalNode({ const containerRef = useRef(null) const isPointerResizingRef = useRef(false) const lastSyncedPtySizeRef = useRef<{ cols: number; rows: number } | null>(null) + const suppressPtyResizeRef = useRef(false) const commandInputStateRef = useRef(createTerminalCommandInputState()) const onCommandRunRef = useRef(onCommandRun) const isTerminalHydratedRef = useRef(false) @@ -118,11 +119,11 @@ export function TerminalNode({ }) useEffect(() => { lastSyncedPtySizeRef.current = null + suppressPtyResizeRef.current = false commandInputStateRef.current = createTerminalCommandInputState() isTerminalHydratedRef.current = false setIsTerminalHydrated(false) }, [sessionId]) - const syncTerminalSize = useCallback(() => { syncTerminalNodeSize({ terminalRef, @@ -131,6 +132,7 @@ export function TerminalNode({ isPointerResizingRef, lastSyncedPtySizeRef, sessionId, + shouldResizePty: !suppressPtyResizeRef.current, }) }, [sessionId]) const applyTerminalTheme = useTerminalThemeApplier({ @@ -153,9 +155,9 @@ export function TerminalNode({ if (sessionId.trim().length === 0) { return undefined } - const ptyWithOptionalAttach = resolveAttachablePtyApi() const cachedScreenState = getCachedTerminalScreenState(nodeId, sessionId) + suppressPtyResizeRef.current = Boolean(cachedScreenState?.serialized.includes('\u001b[?1049h')) const initialDimensions = resolveInitialTerminalDimensions(cachedScreenState) const scrollbackBuffer = scrollbackBufferRef.current const committedScrollbackBuffer = createRollingTextBuffer({ @@ -170,9 +172,7 @@ export function TerminalNode({ window.opencoveApi.debug?.logTerminalDiagnostics ?? (() => undefined) const terminal = new Terminal({ cursorBlink: true, - // eslint-disable-next-line react-hooks/exhaustive-deps -- terminalFontFamily is intentionally used only as the initial value; reactive updates are handled by a separate effect fontFamily: - terminalFontFamily ?? 'JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', theme: initialTerminalTheme, allowProposedApi: true, @@ -238,21 +238,22 @@ export function TerminalNode({ terminalThemeMode, windowsPty, }) - let isDisposed = false - let shouldForwardTerminalData = false + let isDisposed = false, + shouldForwardTerminalData = false const dataDisposable = terminal.onData(data => { if (!shouldForwardTerminalData) { return } - + if (suppressPtyResizeRef.current) { + suppressPtyResizeRef.current = false + syncTerminalSize() + } ptyWriteQueue.enqueue(data) ptyWriteQueue.flush() - const commandRunHandler = onCommandRunRef.current if (!commandRunHandler) { return } - const parsed = parseTerminalCommandInput(data, commandInputStateRef.current) commandInputStateRef.current = parsed.nextState parsed.commands.forEach(command => { @@ -263,7 +264,10 @@ export function TerminalNode({ if (!shouldForwardTerminalData) { return } - + if (suppressPtyResizeRef.current) { + suppressPtyResizeRef.current = false + syncTerminalSize() + } ptyWriteQueue.enqueue(data, 'binary') ptyWriteQueue.flush() }) @@ -376,6 +380,7 @@ export function TerminalNode({ window.addEventListener('opencove-theme-changed', handleThemeChange) return () => { + suppressPtyResizeRef.current = false const isInvalidated = isCachedTerminalScreenStateInvalidated(nodeId, sessionId) cacheTerminalScreenStateOnUnmount({ nodeId, @@ -428,7 +433,6 @@ export function TerminalNode({ title, kind, ]) - useEffect(() => { const terminal = terminalRef.current if (!terminal) { @@ -438,7 +442,6 @@ export function TerminalNode({ terminal.options.fontSize = terminalFontSize syncTerminalSize() }, [syncTerminalSize, terminalFontSize]) - useEffect(() => { const terminal = terminalRef.current if (!terminal) { @@ -450,14 +453,12 @@ export function TerminalNode({ 'JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' syncTerminalSize() }, [syncTerminalSize, terminalFontFamily]) - useEffect(() => { const frame = requestAnimationFrame(syncTerminalSize) return () => { cancelAnimationFrame(frame) } }, [height, syncTerminalSize, width]) - const hasSelectedDragSurface = isDragSurfaceSelectionMode && (isSelected || isDragging) const { consumeIgnoredClick: consumeIgnoredTerminalBodyClick, @@ -465,7 +466,6 @@ export function TerminalNode({ handlePointerMoveCapture: handleTerminalBodyPointerMoveCapture, handlePointerUp: handleTerminalBodyPointerUp, } = useTerminalBodyClickFallback(onInteractionStart) - return ( 0) { - restoredPayload = `${cachedSerializedScreen}${resolveScrollbackDelta(baseRawSnapshot, snapshot.data)}` + const delta = resolveScrollbackDelta(baseRawSnapshot, snapshot.data) + restoredPayload = shouldSkipRawDeltaForSerializedScreen(cachedSerializedScreen) + ? cachedSerializedScreen + : `${cachedSerializedScreen}${delta}` rawSnapshot = mergeScrollbackSnapshots(baseRawSnapshot, snapshot.data) } else { rawSnapshot = mergeScrollbackSnapshots(persistedSnapshot, snapshot.data) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/syncTerminalNodeSize.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/syncTerminalNodeSize.ts index efde7fc5..e606bb79 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/syncTerminalNodeSize.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/syncTerminalNodeSize.ts @@ -34,6 +34,7 @@ export function syncTerminalNodeSize({ isPointerResizingRef, lastSyncedPtySizeRef, sessionId, + shouldResizePty = true, }: { terminalRef: MutableRefObject fitAddonRef: MutableRefObject @@ -41,6 +42,7 @@ export function syncTerminalNodeSize({ isPointerResizingRef: MutableRefObject lastSyncedPtySizeRef: MutableRefObject<{ cols: number; rows: number } | null> sessionId: string + shouldResizePty?: boolean }): void { const terminal = terminalRef.current const fitAddon = fitAddonRef.current @@ -78,6 +80,10 @@ export function syncTerminalNodeSize({ return } + if (!shouldResizePty) { + return + } + lastSyncedPtySizeRef.current = nextPtySize void window.opencoveApi.pty.resize({ From 3eea93863662743095dca857ef29dadf32b44eee Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 15:43:45 +0800 Subject: [PATCH 19/22] fix: apply alt-screen delta when leaving alternate buffer --- .../terminalNode/hydrateFromSnapshot.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts index d8a65e1a..2d91974e 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts @@ -2,14 +2,25 @@ import type { Terminal } from '@xterm/xterm' import { mergeScrollbackSnapshots, resolveScrollbackDelta } from './scrollback' import type { CachedTerminalScreenState } from './screenStateCache' -const ALT_BUFFER_MARKER = '\u001b[?1049h' +const ALT_BUFFER_ENTER_MARKER = '\u001b[?1049h' +const ALT_BUFFER_EXIT_MARKER = '\u001b[?1049l' -function shouldSkipRawDeltaForSerializedScreen(serialized: string): boolean { +function shouldSkipRawDeltaForSerializedScreen(serialized: string, delta: string): boolean { // xterm serialize addon prefixes alternate buffer content with ESC[?1049h ESC[H. When a TUI is in // alternate buffer, replaying raw PTY deltas (which are capped/truncated) can clobber the screen // with prompt/redraw output that happened while the terminal was detached. Prefer restoring the // committed serialized screen and let live output update it going forward. - return serialized.includes(ALT_BUFFER_MARKER) + if (!serialized.includes(ALT_BUFFER_ENTER_MARKER)) { + return false + } + + // If the process exited the alternate buffer while detached, we must replay the delta so that + // application cursor/raw-mode exits (and the shell prompt) restore correctly. + if (delta.includes(ALT_BUFFER_EXIT_MARKER)) { + return false + } + + return true } export async function hydrateTerminalFromSnapshot({ @@ -48,7 +59,7 @@ export async function hydrateTerminalFromSnapshot({ const snapshot = await takePtySnapshot({ sessionId }) if (cachedSerializedScreen.length > 0) { const delta = resolveScrollbackDelta(baseRawSnapshot, snapshot.data) - restoredPayload = shouldSkipRawDeltaForSerializedScreen(cachedSerializedScreen) + restoredPayload = shouldSkipRawDeltaForSerializedScreen(cachedSerializedScreen, delta) ? cachedSerializedScreen : `${cachedSerializedScreen}${delta}` rawSnapshot = mergeScrollbackSnapshots(baseRawSnapshot, snapshot.data) From 21009337efff99d753a51df4c73b4760eb920200 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 15:59:14 +0800 Subject: [PATCH 20/22] docs: document alt-screen restore delta handling --- docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md index bf1d4eae..2b76a457 100644 --- a/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md +++ b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md @@ -38,8 +38,10 @@ caches an xterm SerializeAddon-based "committed screen state" on unmount. - cache `{ serializedScreen, rawSnapshotBase, cols, rows }` per `nodeId/sessionId` 2. On mount: - write cached `serializedScreen` - - fetch `pty.snapshot` and append only the delta (computed via suffix/prefix overlap) - - this catches up the screen to the latest PTY state without relying on full raw replay + - fetch `pty.snapshot` and compute a delta (suffix/prefix overlap) + - for normal-buffer restores: append the delta to catch up + - for alternate-buffer restores: only append the delta when it contains an explicit alt-buffer exit (`ESC[?1049l`) + otherwise, skip the delta to avoid clobbering the committed full-screen snapshot with prompt/redraw output ## Failure Mode @@ -52,6 +54,11 @@ persisted scrollback, which can be: - stale (publish is debounced) - or trimmed (cap) such that the expected final frame token is missing +Even when the cache contains the expected frame token, restoring can still fail if we immediately +replay the raw PTY delta on top of the committed serialized snapshot. In CI we observed the delta +containing the shell prompt/redraw output that happened while the workspace was inactive, which can +overwrite the last full-screen frame line. + ## Fix Keep the latest committed screen cache even when there are pending writes. @@ -60,6 +67,14 @@ The cache is allowed to be slightly behind; the remount path will still fetch `p apply the delta to catch up. Deleting the cache entirely is worse because it removes the only representation that can preserve alternate-screen semantics when the raw snapshot cap is exceeded. +In addition, treat alternate-screen restores as a special case: + +- Skip replaying the raw PTY delta unless it contains an explicit alt-buffer exit (`ESC[?1049l`). + This keeps "what the user last saw" stable and prevents prompt/redraw output from clobbering the + cached full-screen snapshot. +- Suppress PTY resizes (SIGWINCH) during alternate-screen restore until the user types again, so a + shell redraw cannot wipe the cached frame before it is visible. + ## Verification Local: @@ -79,4 +94,3 @@ CI: - Add bounded "drain pending writes before caching" logic on unmount (avoid UI jank). - Extend `OPENCOVE_TERMINAL_DIAGNOSTICS=1` to log cache/hydrate decision points (cache hit/miss, pending writes, raw snapshot lengths, alt/normal buffer kind). - From 4dd4b7fd93c746e3ad86899c37dbe275f3479905 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 16:42:26 +0800 Subject: [PATCH 21/22] test: stabilize zoomed terminal resize handle drag --- .../e2e/workspace-canvas.drag-resize.spec.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/e2e/workspace-canvas.drag-resize.spec.ts b/tests/e2e/workspace-canvas.drag-resize.spec.ts index 251fdffb..3ee6c0a6 100644 --- a/tests/e2e/workspace-canvas.drag-resize.spec.ts +++ b/tests/e2e/workspace-canvas.drag-resize.spec.ts @@ -264,13 +264,21 @@ test.describe('Workspace Canvas - Drag & Resize', () => { await expect(terminal).toBeVisible() const rightResizer = terminal.locator('[data-testid="terminal-resizer-right"]') - const rightResizerBox = await rightResizer.boundingBox() - if (!rightResizerBox) { + const rightResizerRect = await rightResizer.evaluate(el => { + const rect = el.getBoundingClientRect() + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + } + }) + if (!rightResizerRect || rightResizerRect.width <= 0 || rightResizerRect.height <= 0) { throw new Error('terminal right resizer bounding box unavailable at zoomed resize') } - const startX = rightResizerBox.x + rightResizerBox.width / 2 - const startY = rightResizerBox.y + rightResizerBox.height / 2 + const startX = rightResizerRect.x + rightResizerRect.width / 2 + const startY = rightResizerRect.y + rightResizerRect.height / 2 const pointerDeltaX = 180 const releaseX = startX + pointerDeltaX @@ -282,16 +290,14 @@ test.describe('Workspace Canvas - Drag & Resize', () => { await expect .poll( async () => { - const box = await rightResizer.boundingBox() - if (!box) { - return Number.NaN - } - - return box.x + box.width / 2 + return await rightResizer.evaluate(el => { + const rect = el.getBoundingClientRect() + return rect.x + rect.width / 2 + }) }, { timeout: 10_000 }, ) - .toBeCloseTo(releaseX, 1) + .toBeCloseTo(releaseX, 0) await expect .poll( From 63562d44d8453dace0ef24803a8f3ea91ecbd995 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Mon, 30 Mar 2026 17:06:06 +0800 Subject: [PATCH 22/22] docs: note boundingBox flakiness under zoomed e2e --- docs/DEBUGGING.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index f1e3970b..b192a886 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -104,6 +104,25 @@ pnpm test:e2e - space overlay / drag handle / label 区域是否抢占事件 - 点击点是否过于贴边 +### 4) 缩放/transform 场景避免依赖 `locator.boundingBox()` 做像素命中与断言 + +在 React Flow 缩放(viewport transform)场景下,尤其是 CI 里的 `inactive/offscreen` 窗口模式,`locator.boundingBox()` 偶发返回不稳定坐标,导致鼠标按下点不到目标元素,进而出现“mouse 走完了但 resize/drag 根本没发生”的假操作。 + +建议: + +- 计算鼠标命中点时,优先用 `locator.evaluate(el => el.getBoundingClientRect())` 获取可视坐标,再用其中心点进行 `mouse.move/down/up`。 +- 对像素级对齐断言留出容差(例如降低 `toBeCloseTo` precision,或用自定义 tolerance),避免被平台舍入差/动画 settle 影响。 +- 断言 resize/drag 结果时,优先读持久化状态确认是否真的提交,而不是只看 UI 像素位置。 + +示例(命中点计算): + +```ts +const rect = await locator.evaluate(el => el.getBoundingClientRect()) +const x = rect.x + rect.width / 2 +const y = rect.y + rect.height / 2 +await page.mouse.move(x, y) +``` + ## 持久化与状态污染排查 ### 1) 测试优先使用 seed 状态