diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eeea806..4791ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,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) - UI: unified shared menu overlays to fix prompt template, task session, and related context-menu offset issues. (#121) - 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) 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 状态 diff --git a/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md new file mode 100644 index 00000000..2b76a457 --- /dev/null +++ b/docs/TERMINAL_ANSI_SCREEN_PERSISTENCE.md @@ -0,0 +1,96 @@ +# 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 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 + +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 + +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. + +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. + +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: + +```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/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/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/controlSurface/handlers/sessionHandlers.ts b/src/app/main/controlSurface/handlers/sessionHandlers.ts index d7e7f78a..a2415104 100644 --- a/src/app/main/controlSurface/handlers/sessionHandlers.ts +++ b/src/app/main/controlSurface/handlers/sessionHandlers.ts @@ -1,11 +1,9 @@ -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' import type { ApprovedWorkspaceStore } from '../../../../contexts/workspace/infrastructure/approval/ApprovedWorkspaceStore' import { createAppError } from '../../../../shared/errors/appError' 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, @@ -13,18 +11,14 @@ 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 { ControlSurfacePtyRuntime } from './sessionPtyRuntime' 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/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 cf1307b0..78056efa 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' import { registerSystemIpcHandlers } from '../../../contexts/system/presentation/main-ipc/register' export type { IpcRegistrationDisposable } from './types' @@ -66,6 +67,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 7701685b..eb65a8e4 100644 --- a/src/app/preload/index.d.ts +++ b/src/app/preload/index.d.ts @@ -63,6 +63,7 @@ import type { WriteWorkspaceStateRawInput, WriteTerminalInput, DeleteCanvasImageInput, + TerminalDiagnosticsLogInput, CreateDirectoryInput, ReadDirectoryInput, ReadDirectoryResult, @@ -81,7 +82,12 @@ 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 67ef7916..ff4f20bc 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -65,6 +65,7 @@ import type { WriteWorkspaceStateRawInput, WriteTerminalInput, DeleteCanvasImageInput, + TerminalDiagnosticsLogInput, CreateDirectoryInput, ReadDirectoryInput, ReadDirectoryResult, @@ -80,12 +81,37 @@ import { invokeIpc } from './ipcInvoke' type UnsubscribeFn = () => void +function resolveWindowsPtyMeta(): { backend: 'conpty'; buildNumber: number } | null { + if (process.platform !== 'win32') { + return null + } + + const systemVersion = + typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : '' + const build = Number.parseInt(systemVersion.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', + 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 => 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..e56c1418 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,41 @@ 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 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: 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 +299,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..d7b5d5ef 100644 --- a/src/contexts/agent/presentation/main-ipc/validate.ts +++ b/src/contexts/agent/presentation/main-ipc/validate.ts @@ -6,9 +6,13 @@ import type { ResolveAgentResumeSessionInput, } from '../../../../shared/contracts/dto' import { normalizeProvider } from '../../../../app/main/ipc/normalize' -import { isAbsolute } from 'node:path' +import { isAbsolute, win32 } from 'node:path' import { createAppError } from '../../../../shared/errors/appError' +function isAbsoluteWorkspacePath(path: string): boolean { + return isAbsolute(path) || win32.isAbsolute(path) +} + export function normalizeListModelsPayload(payload: unknown): ListAgentModelsInput { if (!payload || typeof payload !== 'object') { throw createAppError('common.invalid_input', { @@ -43,7 +47,7 @@ export function normalizeResolveResumeSessionPayload( }) } - if (!isAbsolute(cwd)) { + if (!isAbsoluteWorkspacePath(cwd)) { throw createAppError('common.invalid_input', { debugMessage: 'agent:resolve-resume-session requires an absolute cwd', }) @@ -74,7 +78,7 @@ export function normalizeReadLastMessagePayload(payload: unknown): ReadAgentLast throw new Error('Invalid cwd for agent:read-last-message') } - if (!isAbsolute(cwd)) { + if (!isAbsoluteWorkspacePath(cwd)) { throw new Error('agent:read-last-message requires an absolute cwd') } @@ -152,6 +156,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' @@ -177,7 +182,7 @@ export function normalizeLaunchAgentPayload(payload: unknown): LaunchAgentInput }) } - if (!isAbsolute(cwd)) { + if (!isAbsoluteWorkspacePath(cwd)) { throw createAppError('common.invalid_input', { debugMessage: 'agent:launch requires an absolute cwd', }) @@ -186,6 +191,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/settings/domain/agentSettings.customModels.ts b/src/contexts/settings/domain/agentSettings.customModels.ts new file mode 100644 index 00000000..54b3f277 --- /dev/null +++ b/src/contexts/settings/domain/agentSettings.customModels.ts @@ -0,0 +1,8 @@ +export type AgentCustomModelEnabledByProvider = Record + +export type AgentCustomModelByProvider = Record + +export type AgentCustomModelOptionsByProvider = Record< + TProvider, + string[] +> diff --git a/src/contexts/settings/domain/agentSettings.ts b/src/contexts/settings/domain/agentSettings.ts index 6ddde6ef..46471194 100644 --- a/src/contexts/settings/domain/agentSettings.ts +++ b/src/contexts/settings/domain/agentSettings.ts @@ -1,4 +1,9 @@ import type { AppUpdateChannel, AppUpdatePolicy } from '../../../shared/contracts/dto' +import type { + AgentCustomModelByProvider, + AgentCustomModelEnabledByProvider, + AgentCustomModelOptionsByProvider, +} from './agentSettings.customModels' import { normalizeFocusNodeTargetZoom, type FocusNodeTargetZoom } from './focusNodeTargetZoom' import { isValidUpdateChannel, @@ -71,22 +76,7 @@ export { } from './agentSettings.providerMeta' const DEFAULT_TASK_TITLE_PROVIDER: TaskTitleAgentProvider = 'codex' - -export const UI_LANGUAGE_NATIVE_LABEL: Record = { - en: 'English', - 'zh-CN': '简体中文', -} -export type AgentCustomModelEnabledByProvider = { - [provider in AgentProvider]: boolean -} - -export type AgentCustomModelByProvider = { - [provider in AgentProvider]: string -} - -export type AgentCustomModelOptionsByProvider = { - [provider in AgentProvider]: string[] -} +export { UI_LANGUAGE_NATIVE_LABEL } from './agentSettings.uiLanguage' export type { TaskPromptTemplate, TaskPromptTemplatesByWorkspaceId } from './taskPromptTemplates' @@ -99,9 +89,9 @@ export interface AgentSettings { agentProviderOrder: AgentProvider[] agentFullAccess: boolean defaultTerminalProfileId: TerminalProfileId - customModelEnabledByProvider: AgentCustomModelEnabledByProvider - customModelByProvider: AgentCustomModelByProvider - customModelOptionsByProvider: AgentCustomModelOptionsByProvider + customModelEnabledByProvider: AgentCustomModelEnabledByProvider + customModelByProvider: AgentCustomModelByProvider + customModelOptionsByProvider: AgentCustomModelOptionsByProvider taskTitleProvider: TaskTitleProvider taskTitleModel: string taskTagOptions: string[] @@ -323,7 +313,9 @@ export function normalizeAgentSettings(value: unknown): AgentSettings { const legacyModelInput = isRecord(value.modelByProvider) ? value.modelByProvider : {} - const customModelEnabledByProvider = AGENT_PROVIDERS.reduce( + const customModelEnabledByProvider = AGENT_PROVIDERS.reduce< + AgentCustomModelEnabledByProvider + >( (acc, provider) => { const normalizedEnabled = normalizeBoolean(enabledInput[provider]) const legacyModel = normalizeTextValue(legacyModelInput[provider]) @@ -335,7 +327,7 @@ export function normalizeAgentSettings(value: unknown): AgentSettings { { ...DEFAULT_AGENT_SETTINGS.customModelEnabledByProvider }, ) - const customModelByProvider = AGENT_PROVIDERS.reduce( + const customModelByProvider = AGENT_PROVIDERS.reduce>( (acc, provider) => { const current = customModelInput[provider] ?? legacyModelInput[provider] acc[provider] = normalizeTextValue(current) @@ -348,7 +340,9 @@ export function normalizeAgentSettings(value: unknown): AgentSettings { ? value.customModelOptionsByProvider : {} - const customModelOptionsByProvider = AGENT_PROVIDERS.reduce( + const customModelOptionsByProvider = AGENT_PROVIDERS.reduce< + AgentCustomModelOptionsByProvider + >( (acc, provider) => { const options = normalizeUniqueStringArray(optionsInput[provider]) const selectedModel = customModelByProvider[provider] @@ -360,7 +354,7 @@ export function normalizeAgentSettings(value: unknown): AgentSettings { acc[provider] = options return acc }, - AGENT_PROVIDERS.reduce( + AGENT_PROVIDERS.reduce>( (acc, provider) => { acc[provider] = [...DEFAULT_AGENT_SETTINGS.customModelOptionsByProvider[provider]] return acc diff --git a/src/contexts/settings/domain/agentSettings.uiLanguage.ts b/src/contexts/settings/domain/agentSettings.uiLanguage.ts new file mode 100644 index 00000000..3454bc87 --- /dev/null +++ b/src/contexts/settings/domain/agentSettings.uiLanguage.ts @@ -0,0 +1,4 @@ +export const UI_LANGUAGE_NATIVE_LABEL = { + en: 'English', + 'zh-CN': '简体中文', +} as const diff --git a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx index 1aed5625..ea837a81 100644 --- a/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx +++ b/src/contexts/workspace/presentation/renderer/components/TerminalNode.tsx @@ -6,33 +6,37 @@ 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, } from './terminalNode/commandInput' import { createPtyWriteQueue, handleTerminalCustomKeyEvent } from './terminalNode/inputBridge' import { registerTerminalLayoutSync } from './terminalNode/layoutSync' -import { mergeScrollbackSnapshots, resolveScrollbackDelta } from './terminalNode/scrollback' import { clearCachedTerminalScreenStateInvalidation, getCachedTerminalScreenState, isCachedTerminalScreenStateInvalidated, - setCachedTerminalScreenState, } from './terminalNode/screenStateCache' +import { resolveAttachablePtyApi } from './terminalNode/attachablePty' +import { cacheTerminalScreenStateOnUnmount } from './terminalNode/cacheTerminalScreenState' 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 { selectDragSurfaceSelectionMode, selectViewportInteractionActive, @@ -76,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) @@ -114,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, @@ -127,6 +132,7 @@ export function TerminalNode({ isPointerResizingRef, lastSyncedPtySizeRef, sessionId, + shouldResizePty: !suppressPtyResizeRef.current, }) }, [sessionId]) const applyTerminalTheme = useTerminalThemeApplier({ @@ -149,26 +155,30 @@ export function TerminalNode({ if (sessionId.trim().length === 0) { return undefined } - - const ptyWithOptionalAttach = window.opencoveApi.pty as typeof window.opencoveApi.pty & { - attach?: (payload: { sessionId: string }) => Promise - detach?: (payload: { sessionId: string }) => Promise - } + 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({ + maxChars: MAX_SCROLLBACK_CHARS, + initial: scrollbackBuffer.snapshot(), + }) 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, - // 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, convertEol: true, scrollback: 5000, + ...(windowsPty ? { windowsPty } : {}), ...(initialDimensions ?? {}), }) const fitAddon = new FitAddon() @@ -178,15 +188,14 @@ export function TerminalNode({ terminalRef.current = terminal fitAddonRef.current = fitAddon - const terminalSupportsSearch = + const disposeTerminalFind = typeof (terminal as unknown as { onWriteParsed?: unknown }).onWriteParsed === 'function' - const disposeTerminalFind = terminalSupportsSearch - ? (() => { - const searchAddon = new SearchAddon() - terminal.loadAddon(searchAddon) - return bindSearchAddonToFind(searchAddon) - })() - : () => undefined + ? (() => { + const searchAddon = new SearchAddon() + terminal.loadAddon(searchAddon) + return bindSearchAddonToFind(searchAddon) + })() + : () => undefined let disposeTerminalSelectionTestHandle: () => void = () => undefined const ptyWriteQueue = createPtyWriteQueue(({ data, encoding }) => window.opencoveApi.pty.write({ @@ -211,27 +220,40 @@ export function TerminalNode({ if (window.opencoveApi.meta.isTest) { disposeTerminalSelectionTestHandle = registerTerminalSelectionTestHandle(nodeId, terminal) } + syncTerminalSize() requestAnimationFrame(syncTerminalSize) if (window.opencoveApi.meta.isTest) { terminal.focus() } } - - let isDisposed = false - let shouldForwardTerminalData = false + const terminalDiagnostics = registerTerminalDiagnostics({ + enabled: diagnosticsEnabled, + emit: logTerminalDiagnostics, + nodeId, + sessionId, + nodeKind: kind === 'agent' ? 'agent' : 'terminal', + title, + terminal, + container: containerRef.current, + terminalThemeMode, + windowsPty, + }) + 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 => { @@ -242,125 +264,102 @@ export function TerminalNode({ if (!shouldForwardTerminalData) { return } - + if (suppressPtyResizeRef.current) { + suppressPtyResizeRef.current = false + syncTerminalSize() + } ptyWriteQueue.enqueue(data, 'binary') ptyWriteQueue.flush() }) let isHydrating = true - const bufferedDataChunks: string[] = [] - let bufferedExitCode: number | null = null + const hydrationBuffer = { dataChunks: [] as string[], exitCode: null as number | 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) const unsubscribeData = ptyEventHub.onSessionData(sessionId, event => { if (isHydrating) { - bufferedDataChunks.push(event.data) + hydrationBuffer.dataChunks.push(event.data) return } - outputScheduler.handleChunk(event.data) }) const unsubscribeExit = ptyEventHub.onSessionExit(sessionId, event => { if (isHydrating) { - bufferedExitCode = event.exitCode + hydrationBuffer.exitCode = event.exitCode return } - const exitMessage = `\r\n[process exited with code ${event.exitCode}]\r\n` - outputScheduler.handleChunk(exitMessage, { immediateScrollbackPublish: true }) + outputScheduler.handleChunk(`\r\n[process exited with code ${event.exitCode}]\r\n`, { + immediateScrollbackPublish: true, + }) }) 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) - revealHydratedTerminal(syncTerminalSize, () => { - if (!isDisposed) { - isTerminalHydratedRef.current = true - setIsTerminalHydrated(true) - } + finalizeTerminalHydration({ + isDisposed: () => isDisposed, + rawSnapshot, + scrollbackBuffer, + ptyWriteQueue, + bufferedDataChunks: hydrationBuffer.dataChunks, + bufferedExitCode: hydrationBuffer.exitCode, + terminal, + committedScrollbackBuffer, + onCommittedScreenState: nextRawSnapshot => { + committedScreenStateRecorder.record(nextRawSnapshot) + }, + markScrollbackDirty, + logHydrated: details => { + terminalDiagnostics.logHydrated(details) + }, + syncTerminalSize, + onRevealed: () => { + if (!isDisposed) { + isTerminalHydratedRef.current = true + setIsTerminalHydrated(true) + } + }, }) + hydrationBuffer.exitCode = null } - 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, + onHydratedWriteCommitted: rawSnapshot => { + committedScrollbackBuffer.set(rawSnapshot) + committedScreenStateRecorder.record(rawSnapshot) + }, + finalizeHydration: rawSnapshot => { shouldForwardTerminalData = true finalizeHydration(rawSnapshot) - } - } - - void hydrateFromSnapshot() + }, + }) const resizeObserver = new ResizeObserver(() => { syncTerminalSize() @@ -381,29 +380,23 @@ export function TerminalNode({ window.addEventListener('opencove-theme-changed', handleThemeChange) return () => { + suppressPtyResizeRef.current = false const isInvalidated = isCachedTerminalScreenStateInvalidated(nodeId, sessionId) - - 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) { - setCachedTerminalScreenState(nodeId, { - sessionId, - serialized: serializedScreen, - rawSnapshot: scrollbackBuffer.snapshot(), - cols: terminal.cols, - rows: terminal.rows, - }) - } - } + cacheTerminalScreenStateOnUnmount({ + nodeId, + isInvalidated, + isTerminalHydrated: isTerminalHydratedRef.current, + hasPendingWrites: outputScheduler.hasPendingWrites(), + rawSnapshot: scrollbackBuffer.snapshot(), + resolveCommittedScreenState: committedScreenStateRecorder.resolve, + }) cancelMouseServicePatch() isDisposed = true const detachPromise = ptyWithOptionalAttach.detach?.({ sessionId }) void detachPromise?.catch(() => undefined) disposeLayoutSync() + terminalDiagnostics.dispose() window.removeEventListener('opencove-theme-changed', handleThemeChange) resizeObserver.disconnect() dataDisposable.dispose() @@ -437,8 +430,9 @@ export function TerminalNode({ sessionId, syncTerminalSize, terminalThemeMode, + title, + kind, ]) - useEffect(() => { const terminal = terminalRef.current if (!terminal) { @@ -448,7 +442,6 @@ export function TerminalNode({ terminal.options.fontSize = terminalFontSize syncTerminalSize() }, [syncTerminalSize, terminalFontSize]) - useEffect(() => { const terminal = terminalRef.current if (!terminal) { @@ -460,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, @@ -475,7 +466,6 @@ export function TerminalNode({ handlePointerMoveCapture: handleTerminalBodyPointerMoveCapture, handlePointerUp: handleTerminalBodyPointerUp, } = useTerminalBodyClickFallback(onInteractionStart) - return ( Promise + detach?: (payload: { sessionId: string }) => Promise +} + +export function resolveAttachablePtyApi(): AttachablePtyApi { + return window.opencoveApi.pty as AttachablePtyApi +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts new file mode 100644 index 00000000..7709b113 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/cacheTerminalScreenState.ts @@ -0,0 +1,40 @@ +import type { CommittedTerminalScreenState } from './committedScreenState' +import { setCachedTerminalScreenState } from './screenStateCache' + +export function cacheTerminalScreenStateOnUnmount({ + nodeId, + isInvalidated, + isTerminalHydrated, + hasPendingWrites, + rawSnapshot, + resolveCommittedScreenState, +}: { + nodeId: string + isInvalidated: boolean + isTerminalHydrated: boolean + hasPendingWrites: boolean + rawSnapshot: string + resolveCommittedScreenState: ( + rawSnapshot: string, + options?: { allowSerializeFallback?: boolean }, + ) => CommittedTerminalScreenState | null +}): void { + if (isInvalidated || !isTerminalHydrated) { + return + } + + const latestCommittedScreenState = resolveCommittedScreenState(rawSnapshot, { + allowSerializeFallback: !hasPendingWrites, + }) + if (!latestCommittedScreenState) { + return + } + + setCachedTerminalScreenState(nodeId, { + 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..d17b9d91 --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/committedScreenState.ts @@ -0,0 +1,153 @@ +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({ + 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, + bufferKind: resolveTerminalBufferKind(terminal), + } +} + +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, + options?: { allowSerializeFallback?: boolean }, + ) => CommittedTerminalScreenState | null +} { + let latestCommittedScreenState: CommittedTerminalScreenState | null = null + + return { + record: rawSnapshot => { + latestCommittedScreenState = + captureCommittedTerminalScreenState({ + serializeAddon, + sessionId, + rawSnapshot, + terminal, + }) ?? latestCommittedScreenState + }, + resolve: (rawSnapshot, options) => { + const allowSerializeFallback = options?.allowSerializeFallback !== false + 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, + sessionId, + rawSnapshot, + terminal, + }) + } + + return latestCommittedScreenState + }, + } +} 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/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 new file mode 100644 index 00000000..2d91974e --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/hydrateFromSnapshot.ts @@ -0,0 +1,87 @@ +import type { Terminal } from '@xterm/xterm' +import { mergeScrollbackSnapshots, resolveScrollbackDelta } from './scrollback' +import type { CachedTerminalScreenState } from './screenStateCache' + +const ALT_BUFFER_ENTER_MARKER = '\u001b[?1049h' +const ALT_BUFFER_EXIT_MARKER = '\u001b[?1049l' + +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. + 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({ + attachPromise, + sessionId, + terminal, + cachedScreenState, + persistedSnapshot, + takePtySnapshot, + isDisposed, + onHydratedWriteCommitted, + finalizeHydration, +}: { + attachPromise: Promise + sessionId: string + terminal: Terminal + cachedScreenState: CachedTerminalScreenState | null + persistedSnapshot: string + takePtySnapshot: (payload: { sessionId: string }) => Promise<{ data: string }> + isDisposed: () => boolean + onHydratedWriteCommitted: (rawSnapshot: string) => void + 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) { + const delta = resolveScrollbackDelta(baseRawSnapshot, snapshot.data) + restoredPayload = shouldSkipRawDeltaForSerializedScreen(cachedSerializedScreen, delta) + ? cachedSerializedScreen + : `${cachedSerializedScreen}${delta}` + 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, () => { + onHydratedWriteCommitted(rawSnapshot) + finalizeHydration(rawSnapshot) + }) + return + } + + finalizeHydration(rawSnapshot) +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler.ts index 5e0d5da5..f60515ab 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 @@ -46,6 +48,7 @@ export function createTerminalOutputScheduler({ let isDisposed = false let isDraining = false let isViewportInteractionActive = false + let hasDirectWriteInFlight = false const hasPending = (): boolean => { return pendingWritesHead < pendingWrites.length @@ -125,6 +128,10 @@ export function createTerminalOutputScheduler({ return } + if (hasDirectWriteInFlight && !force) { + return + } + const canDrainDuringViewportInteraction = allowDuringViewportInteraction || force const shouldBlock = isViewportInteractionActive && !canDrainDuringViewportInteraction if (shouldBlock) { @@ -181,6 +188,7 @@ export function createTerminalOutputScheduler({ remainingBudget -= chunk.length terminal.write(chunk, () => { + onWriteCommitted?.(chunk) pendingWriteFrame = window.requestAnimationFrame(() => { pendingWriteFrame = null drainStep() @@ -199,7 +207,8 @@ export function createTerminalOutputScheduler({ scrollbackBuffer.append(data) markScrollbackDirty(chunkOptions?.immediateScrollbackPublish === true) - const shouldDeferWrite = isViewportInteractionActive || isDraining || hasPending() + const shouldDeferWrite = + isViewportInteractionActive || isDraining || hasPending() || hasDirectWriteInFlight if (shouldDeferWrite) { enqueue(data) @@ -217,7 +226,14 @@ export function createTerminalOutputScheduler({ return } - terminal.write(data) + hasDirectWriteInFlight = true + terminal.write(data, () => { + hasDirectWriteInFlight = false + onWriteCommitted?.(data) + if (!isViewportInteractionActive && hasPending()) { + flush() + } + }) } const onViewportInteractionActiveChange = (isActive: boolean) => { @@ -235,7 +251,7 @@ export function createTerminalOutputScheduler({ return { handleChunk, onViewportInteractionActiveChange, - hasPendingWrites: () => hasPending() || isDraining, + hasPendingWrites: () => hasPending() || isDraining || hasDirectWriteInFlight, dispose: () => { isDisposed = true cancelViewportFlushTimer() @@ -246,6 +262,7 @@ export function createTerminalOutputScheduler({ pendingWrites.length = 0 pendingWritesHead = 0 pendingWriteChars = 0 + hasDirectWriteInFlight = false isDraining = false }, } 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/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/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/screenStateCache.ts index 68dfedba..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, @@ -72,6 +81,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/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({ diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/testHarness.ts index 75907e3b..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, @@ -8,6 +9,14 @@ 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 + 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 @@ -95,6 +104,31 @@ 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, + } + }, + 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/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..62a116e5 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'], @@ -53,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 } @@ -115,37 +121,40 @@ 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 { 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, @@ -165,44 +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, - 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)) { @@ -211,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 }, ) } }, @@ -234,6 +249,7 @@ export function useWorkspaceCanvasAgentNodeLifecycle({ agentFullAccess, buildAgentNodeTitle, bumpAgentLaunchToken, + defaultTerminalProfileId, isAgentLaunchTokenCurrent, nodesRef, setNodes, @@ -255,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/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/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/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/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 dda2acda..b2a63480 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/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/src/shared/contracts/ipc/channels.ts b/src/shared/contracts/ipc/channels.ts index d9024f20..6d589ec7 100644 --- a/src/shared/contracts/ipc/channels.ts +++ b/src/shared/contracts/ipc/channels.ts @@ -39,6 +39,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/src/shared/types/font-list.d.ts b/src/shared/types/font-list.d.ts new file mode 100644 index 00000000..168d3827 --- /dev/null +++ b/src/shared/types/font-list.d.ts @@ -0,0 +1,3 @@ +declare module 'font-list' { + export function getFonts(options?: { disableQuoting?: boolean }): Promise +} diff --git a/tests/contract/ipc/agentIpc.validate.spec.ts b/tests/contract/ipc/agentIpc.validate.spec.ts new file mode 100644 index 00000000..238d365d --- /dev/null +++ b/tests/contract/ipc/agentIpc.validate.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { getAppErrorDebugMessage, OpenCoveAppError } from '../../../src/shared/errors/appError' + +describe('agent IPC validation', () => { + it('accepts Windows absolute cwd values on non-Windows runners', async () => { + const { + normalizeLaunchAgentPayload, + normalizeReadLastMessagePayload, + normalizeResolveResumeSessionPayload, + } = await import('../../../src/contexts/agent/presentation/main-ipc/validate') + + expect( + normalizeLaunchAgentPayload({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + prompt: 'hello', + }), + ).toEqual( + expect.objectContaining({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + prompt: 'hello', + }), + ) + + expect( + normalizeResolveResumeSessionPayload({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + startedAt: '2026-03-28T15:59:05.000Z', + }), + ).toEqual({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + startedAt: '2026-03-28T15:59:05.000Z', + }) + + expect( + normalizeReadLastMessagePayload({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + startedAt: '2026-03-28T15:59:05.000Z', + }), + ).toEqual({ + provider: 'codex', + cwd: 'C:\\Users\\deadwave\\project', + startedAt: '2026-03-28T15:59:05.000Z', + resumeSessionId: null, + }) + }) + + it('still rejects relative cwd values for agent launch', async () => { + const { normalizeLaunchAgentPayload } = + await import('../../../src/contexts/agent/presentation/main-ipc/validate') + + try { + normalizeLaunchAgentPayload({ + provider: 'codex', + cwd: 'relative\\path', + prompt: 'hello', + }) + throw new Error('Expected normalizeLaunchAgentPayload to throw') + } catch (error) { + expect(error).toBeInstanceOf(OpenCoveAppError) + expect((error as OpenCoveAppError).code).toBe('common.invalid_input') + expect(getAppErrorDebugMessage(error)).toBe('agent:launch requires an absolute cwd') + } + }) +}) 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/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() + } + }) +}) 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( diff --git a/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts b/tests/e2e/workspace-canvas.persistence.ansi-screen.spec.ts index fa4c0aa7..37184490 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, { @@ -49,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[";', @@ -70,13 +81,49 @@ 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) + 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/) + 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/) + 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 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 => { + 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 { 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/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/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/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, + }) + }) +}) diff --git a/tests/unit/contexts/terminalNode.output-scheduler.spec.ts b/tests/unit/contexts/terminalNode.output-scheduler.spec.ts new file mode 100644 index 00000000..6e346c57 --- /dev/null +++ b/tests/unit/contexts/terminalNode.output-scheduler.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTerminalOutputScheduler } from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/outputScheduler' + +describe('terminal output scheduler', () => { + it('tracks direct writes as pending until the write callback commits', () => { + const writeCallbacks: Array<() => void> = [] + const terminal = { + write: vi.fn((_data: string, callback?: () => void) => { + if (callback) { + writeCallbacks.push(callback) + } + }), + } + const onWriteCommitted = vi.fn() + + const scheduler = createTerminalOutputScheduler({ + terminal: terminal as never, + scrollbackBuffer: { append: vi.fn() }, + markScrollbackDirty: vi.fn(), + onWriteCommitted, + }) + + scheduler.handleChunk('FRAME_29999_TOKEN') + + expect(scheduler.hasPendingWrites()).toBe(true) + expect(onWriteCommitted).not.toHaveBeenCalled() + + writeCallbacks.shift()?.() + + expect(onWriteCommitted).toHaveBeenCalledWith('FRAME_29999_TOKEN') + expect(scheduler.hasPendingWrites()).toBe(false) + }) + + it('queues later chunks until the in-flight direct write completes', () => { + const writeCallbacks: Array<() => void> = [] + const writes: string[] = [] + const originalRequestAnimationFrame = window.requestAnimationFrame + const originalCancelAnimationFrame = window.cancelAnimationFrame + + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + window.cancelAnimationFrame = vi.fn() + + try { + const terminal = { + write: vi.fn((data: string, callback?: () => void) => { + writes.push(data) + if (callback) { + writeCallbacks.push(callback) + } + }), + } + + const scheduler = createTerminalOutputScheduler({ + terminal: terminal as never, + scrollbackBuffer: { append: vi.fn() }, + markScrollbackDirty: vi.fn(), + }) + + scheduler.handleChunk('FIRST') + scheduler.handleChunk('SECOND') + + expect(writes).toEqual(['FIRST']) + expect(scheduler.hasPendingWrites()).toBe(true) + + writeCallbacks.shift()?.() + + expect(writes).toEqual(['FIRST', 'SECOND']) + + writeCallbacks.shift()?.() + + expect(scheduler.hasPendingWrites()).toBe(false) + } finally { + window.requestAnimationFrame = originalRequestAnimationFrame + window.cancelAnimationFrame = originalCancelAnimationFrame + } + }) +}) 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', + }), + ) + }) +}) 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..b7460e58 --- /dev/null +++ b/tests/unit/contexts/terminalNode.screen-cache.pending-writes.spec.ts @@ -0,0 +1,78 @@ +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('keeps the 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')).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', () => { + 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.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, + }) + }) + }) }) 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',