diff --git a/extensions/opencode/freshell.json b/extensions/opencode/freshell.json index db0099152..1bfb4482e 100644 --- a/extensions/opencode/freshell.json +++ b/extensions/opencode/freshell.json @@ -16,7 +16,11 @@ "bypassPermissions": "{\"edit\":\"allow\",\"bash\":\"allow\"}" }, "supportsPermissionMode": true, - "supportsModel": true + "supportsModel": true, + "terminalBehavior": { + "preferredRenderer": "canvas", + "scrollInputPolicy": "fallbackToCursorKeysWhenAltScreenMouseCapture" + } }, "picker": { "group": "agents" diff --git a/server/extension-manager.ts b/server/extension-manager.ts index d073b59a1..dfb48e678 100644 --- a/server/extension-manager.ts +++ b/server/extension-manager.ts @@ -182,6 +182,7 @@ export class ExtensionManager extends EventEmitter { supportsSandbox: manifest.cli.supportsSandbox, supportsResume: !!manifest.cli.resumeArgs, resumeCommandTemplate, + terminalBehavior: manifest.cli.terminalBehavior, } } diff --git a/server/extension-manifest.ts b/server/extension-manifest.ts index f2d85db05..ec79bb63b 100644 --- a/server/extension-manifest.ts +++ b/server/extension-manifest.ts @@ -42,6 +42,11 @@ const ServerConfigSchema = z.strictObject({ singleton: z.boolean().optional().default(true), }) +const TerminalBehaviorConfigSchema = z.strictObject({ + preferredRenderer: z.enum(['canvas']).optional(), + scrollInputPolicy: z.enum(['native', 'fallbackToCursorKeysWhenAltScreenMouseCapture']).optional(), +}) + const CliConfigSchema = z.strictObject({ command: z.string().min(1), args: z.array(z.string()).optional().default([]), @@ -56,6 +61,7 @@ const CliConfigSchema = z.strictObject({ supportsPermissionMode: z.boolean().optional(), supportsModel: z.boolean().optional(), // shows model field in SettingsView supportsSandbox: z.boolean().optional(), // shows sandbox selector in SettingsView + terminalBehavior: TerminalBehaviorConfigSchema.optional(), }) // ────────────────────────────────────────────────────────────── diff --git a/shared/extension-types.ts b/shared/extension-types.ts index 9ed048ce0..7b62b75d4 100644 --- a/shared/extension-types.ts +++ b/shared/extension-types.ts @@ -37,5 +37,9 @@ export interface ClientExtensionEntry { supportsSandbox?: boolean supportsResume?: boolean resumeCommandTemplate?: string[] // e.g., ["claude", "--resume", "{{sessionId}}"] + terminalBehavior?: { + preferredRenderer?: 'canvas' + scrollInputPolicy?: 'native' | 'fallbackToCursorKeysWhenAltScreenMouseCapture' + } } } diff --git a/src/App.tsx b/src/App.tsx index 6c60a0490..6d4940902 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -705,22 +705,24 @@ export default function App() { if (!msg?.type) return if (msg.type === 'ready') { const ready = ReadyMessageSchema.safeParse(msg) + const nextServerInstanceId = ready.success ? ready.data.serverInstanceId : undefined if ( ready.success && lastReadyServerInstanceId && - lastReadyServerInstanceId !== ready.data.serverInstanceId + lastReadyServerInstanceId !== nextServerInstanceId ) { platformCapabilitiesLoaded = false + dispatch(setRegistry([])) } if (ready.success) { - lastReadyServerInstanceId = ready.data.serverInstanceId + lastReadyServerInstanceId = nextServerInstanceId } // If the initial connect attempt failed before ready, WsClient may still auto-reconnect. // Treat 'ready' as the source of truth for connection status. resetCodexActivityOverlay() dispatch(setError(undefined)) dispatch(setStatus('ready')) - dispatch(setServerInstanceId(ready.success ? ready.data.serverInstanceId : undefined)) + dispatch(setServerInstanceId(nextServerInstanceId)) const newBootId = ready.success ? ready.data.bootId : undefined const previousBootId = appStore.getState().connection.bootId const serverRestarted = !!previousBootId && previousBootId !== newBootId @@ -888,6 +890,14 @@ export default function App() { // sidebar snapshot. The HTTP bootstrap window remains the source of truth. if (ws.isReady) { if (cancelled) return + const previousServerInstanceId = appStore.getState().connection.serverInstanceId + if ( + previousServerInstanceId && + ws.serverInstanceId && + previousServerInstanceId !== ws.serverInstanceId + ) { + dispatch(setRegistry([])) + } lastReadyServerInstanceId = ws.serverInstanceId resetCodexActivityOverlay() dispatch(setError(undefined)) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 2f3b4310b..722c08c93 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -38,6 +38,7 @@ import { type AttachSeqState, } from '@/lib/terminal-attach-seq-state' import { useMobile } from '@/hooks/useMobile' +import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' import { findLocalFilePaths } from '@/lib/path-utils' import { findUrls } from '@/lib/url-utils' import { setHoveredUrl, clearHoveredUrl } from '@/lib/terminal-hovered-url' @@ -77,6 +78,13 @@ import type { PaneContent, PaneContentInput, PaneRefreshRequest, TerminalPaneCon import '@xterm/xterm/css/xterm.css' import { getHydrationQueue } from '@/lib/hydration-queue' import { createLogger } from '@/lib/client-logger' +import { + getProviderTerminalBehavior, + prefersCanvasRenderer, + providerUsesExtensionTerminalBehavior, + scrollLinesToCursorKeys, + shouldTranslateScrollToCursorKeys, +} from '@/lib/terminal-behavior' const log = createLogger('TerminalView') @@ -359,6 +367,14 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter // Extract terminal-specific fields (safe because we check kind later) const isTerminal = paneContent.kind === 'terminal' const terminalContent = isTerminal ? paneContent : null + const extensions = useAppSelector((s) => s.extensions?.entries ?? [], shallowEqual) + const shouldResolveProviderBehavior = isTerminal && providerUsesExtensionTerminalBehavior(terminalContent?.mode) + const extensionRegistryReady = useEnsureExtensionsRegistry(shouldResolveProviderBehavior) + const providerBehavior = useMemo( + () => getProviderTerminalBehavior(terminalContent?.mode, extensions), + [terminalContent?.mode, extensions], + ) + const shouldWaitForProviderBehavior = shouldResolveProviderBehavior && !extensionRegistryReady const terminalSearchState = useAppSelector((state) => { const terminalId = terminalContent?.terminalId if (!terminalId) return null @@ -402,6 +418,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter pendingSinceSeq: 0, }) const contentRef = useRef(terminalContent) + const providerBehaviorRef = useRef(providerBehavior) const refreshRequestRef = useRef(refreshRequest) const handledRefreshRequestIdRef = useRef(null) const hasMountedRefreshEffectRef = useRef(false) @@ -517,6 +534,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter attentionDismissRef.current = settings.panes?.attentionDismiss ?? 'click' debugRef.current = !!settings.logging?.debug refreshRequestRef.current = refreshRequest + providerBehaviorRef.current = providerBehavior const shouldFocusActiveTerminal = !hidden && activeTabId === tabId && activePaneId === paneId @@ -537,6 +555,47 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter lastSessionActivityAtRef.current = 0 }, [terminalContent?.resumeSessionId]) + const sendInput = useCallback((data: string) => { + const tid = terminalIdRef.current + if (!tid) return + // In 'type' mode, clear attention when user sends input. + // In 'click' mode, attention is cleared by the notification hook on tab switch. + if (attentionDismissRef.current === 'type') { + if (hasAttentionRef.current) { + dispatch(clearTabAttention({ tabId })) + } + if (hasPaneAttentionRef.current) { + dispatch(clearPaneAttention({ paneId })) + } + } + if (contentRef.current?.mode === 'claude' && isClaudeTurnSubmit(data)) { + turnCompletedSinceLastInputRef.current = false + dispatch(setPaneRuntimeActivity({ + paneId: paneIdRef.current, + source: 'terminal', + phase: 'pending', + })) + } + ws.send({ type: 'terminal.input', terminalId: tid, data }) + }, [dispatch, tabId, paneId, ws]) + + const translateScrollLinesToInput = useCallback((term: Terminal, lines: number): boolean => { + if (!terminalIdRef.current || lines === 0) return false + + const shouldTranslate = shouldTranslateScrollToCursorKeys({ + scrollInputPolicy: providerBehaviorRef.current.scrollInputPolicy, + altBufferActive: term.buffer.active.type === 'alternate', + mouseTrackingMode: term.modes.mouseTrackingMode, + }) + if (!shouldTranslate) return false + + const sequence = scrollLinesToCursorKeys(lines, term.modes.applicationCursorKeysMode) + if (!sequence) return false + + sendInput(sequence) + return true + }, [sendInput]) + useEffect(() => { if (!isMobile || typeof window === 'undefined' || !window.visualViewport) { setKeyboardInsetPx(0) @@ -654,6 +713,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter if (!isMobile || !touchActiveRef.current) return const touch = event.touches[0] if (!touch) return + const term = termRef.current + if (!term) return const deltaX = Math.abs(touch.clientX - touchStartXRef.current) const deltaYFromStart = Math.abs(touch.clientY - touchStartYRef.current) @@ -672,10 +733,13 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter const rawLines = touchScrollAccumulatorRef.current / TOUCH_SCROLL_PIXELS_PER_LINE const lines = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines) if (lines !== 0) { - termRef.current?.scrollLines(lines) + if (!translateScrollLinesToInput(term, lines)) { + term.scrollLines(lines) + } + touchScrollAccumulatorRef.current -= lines * TOUCH_SCROLL_PIXELS_PER_LINE } - }, [clearLongPressTimer, isMobile]) + }, [clearLongPressTimer, isMobile, translateScrollLinesToInput]) const handleMobileTouchEnd = useCallback((event: ReactTouchEvent) => { if (!isMobile) return @@ -888,30 +952,6 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter setPendingOsc52Event(event) }, [attemptOsc52ClipboardWrite]) - const sendInput = useCallback((data: string) => { - const tid = terminalIdRef.current - if (!tid) return - // In 'type' mode, clear attention when user sends input. - // In 'click' mode, attention is cleared by the notification hook on tab switch. - if (attentionDismissRef.current === 'type') { - if (hasAttentionRef.current) { - dispatch(clearTabAttention({ tabId })) - } - if (hasPaneAttentionRef.current) { - dispatch(clearPaneAttention({ paneId })) - } - } - if (contentRef.current?.mode === 'claude' && isClaudeTurnSubmit(data)) { - turnCompletedSinceLastInputRef.current = false - dispatch(setPaneRuntimeActivity({ - paneId: paneIdRef.current, - source: 'terminal', - phase: 'pending', - })) - } - ws.send({ type: 'terminal.input', terminalId: tid, data }) - }, [dispatch, tabId, paneId, ws]) - const resetStartupProbeParser = useCallback((opts?: { discardReplayRemainder?: boolean }) => { const pendingProbe = startupProbeStateRef.current.pending if (opts?.discardReplayRemainder) { @@ -1111,6 +1151,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter // Init xterm once useEffect(() => { if (!isTerminal) return + if (shouldWaitForProviderBehavior) return if (!containerRef.current) return if (mountedRef.current && termRef.current) return mountedRef.current = true @@ -1161,11 +1202,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter }, }) const rendererMode = settings.terminal.renderer ?? 'auto' - // OpenCode paints a dense truecolor light surface that currently renders - // unreliably through xterm WebGL on Chrome/Windows. Keep auto mode on the - // safer canvas path for that provider unless the user explicitly forces WebGL. const enableWebgl = rendererMode === 'webgl' - || (rendererMode === 'auto' && paneContent.mode !== 'opencode') + || (rendererMode === 'auto' && !prefersCanvasRenderer(paneContent.mode, extensions)) let runtime = createNoopRuntime() try { runtime = createTerminalRuntime({ terminal: term, enableWebgl }) @@ -1192,6 +1230,16 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter term.open(containerRef.current) const requestModeBypass = registerTerminalRequestModeBypass(term, sendInput) + term.attachCustomWheelEventHandler((event) => { + const lines = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0 + if (!translateScrollLinesToInput(term, lines)) { + return true + } + + event.preventDefault() + event.stopPropagation() + return false + }) // Register custom link provider for clickable local file paths const filePathLinkDisposable = typeof term.registerLinkProvider === 'function' @@ -1434,7 +1482,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isTerminal]) + }, [isTerminal, providerBehavior.preferredRenderer, shouldWaitForProviderBehavior]) // Ref for tab to avoid re-running effects when tab changes const tabRef = useRef(tab) diff --git a/src/hooks/useEnsureExtensionsRegistry.ts b/src/hooks/useEnsureExtensionsRegistry.ts index cc78b172f..265fe119b 100644 --- a/src/hooks/useEnsureExtensionsRegistry.ts +++ b/src/hooks/useEnsureExtensionsRegistry.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ClientExtensionEntry } from '@shared/extension-types' import { api } from '@/lib/api' import { getAuthToken } from '@/lib/auth' @@ -9,6 +9,14 @@ import { useAppDispatch, useAppSelector } from '@/store/hooks' const EMPTY_EXTENSION_ENTRIES: ClientExtensionEntry[] = [] const log = createLogger('useEnsureExtensionsRegistry') +type RegistryLoadRecord = { + entries: ClientExtensionEntry[] + promise: Promise | null + status: 'loaded' | 'loading' +} + +const registryLoadCache = new Map() + function resolveExtensionsRegistryPath(): string { if (typeof window === 'undefined') return '/api/extensions' const href = window.location?.href @@ -18,36 +26,89 @@ function resolveExtensionsRegistryPath(): string { return new URL('/api/extensions', href).toString() } -export function useEnsureExtensionsRegistry(enabled = true) { +function resolveRegistryLoadKey(serverInstanceId: string): string { + return serverInstanceId || '__default__' +} + +export function resetEnsureExtensionsRegistryCacheForTests() { + registryLoadCache.clear() +} + +export function useEnsureExtensionsRegistry(enabled = true): boolean { const dispatch = useAppDispatch() const extensionEntries = useAppSelector((s) => s.extensions?.entries ?? EMPTY_EXTENSION_ENTRIES) const connectionStatus = useAppSelector((s) => s.connection?.status ?? 'disconnected') const serverInstanceId = useAppSelector((s) => s.connection?.serverInstanceId ?? '') - const requestedRef = useRef(false) + const requestedRef = useRef(null) + const [loadSettled, setLoadSettled] = useState(false) - useEffect(() => { - if (!enabled || extensionEntries.length > 0 || requestedRef.current) return - if (!getAuthToken()) return - const get = (api as { get?: ((path: string) => Promise | T) }).get - if (typeof get !== 'function') return + const get = (api as { get?: ((path: string) => Promise | T) }).get + const loadKey = resolveRegistryLoadKey(serverInstanceId) + const canLoad = enabled + && extensionEntries.length === 0 + && !!getAuthToken() + && typeof get === 'function' - requestedRef.current = true + useEffect(() => { + if (!enabled || extensionEntries.length > 0) { + setLoadSettled(true) + return + } + if (!getAuthToken() || typeof get !== 'function') { + setLoadSettled(true) + return + } let cancelled = false + const currentRecord = registryLoadCache.get(loadKey) + if (currentRecord?.status === 'loaded') { + dispatch(setRegistry(currentRecord.entries)) + setLoadSettled(true) + return + } - Promise.resolve(get(resolveExtensionsRegistryPath())) + let promise = currentRecord?.promise + if (!promise) { + promise = Promise.resolve(get(resolveExtensionsRegistryPath())) + .then((entries) => { + const normalizedEntries = Array.isArray(entries) ? entries : [] + registryLoadCache.set(loadKey, { + entries: normalizedEntries, + promise: null, + status: 'loaded', + }) + return normalizedEntries + }) + .catch((err) => { + registryLoadCache.delete(loadKey) + throw err + }) + + registryLoadCache.set(loadKey, { + entries: [], + promise, + status: 'loading', + }) + } + + requestedRef.current = loadKey + setLoadSettled(false) + + promise .then((entries) => { - if (cancelled) return - dispatch(setRegistry(Array.isArray(entries) ? entries : [])) + if (cancelled || requestedRef.current !== loadKey) return + dispatch(setRegistry(entries)) + setLoadSettled(true) }) .catch((err) => { - requestedRef.current = false - if (!cancelled) { - log.warn('Failed to load extension registry', err) - } + if (cancelled || requestedRef.current !== loadKey) return + setLoadSettled(true) + log.warn('Failed to load extension registry', err) }) return () => { cancelled = true } - }, [connectionStatus, dispatch, enabled, extensionEntries.length, serverInstanceId]) + }, [connectionStatus, dispatch, enabled, extensionEntries.length, get, loadKey]) + + return !canLoad || loadSettled } diff --git a/src/lib/terminal-behavior.ts b/src/lib/terminal-behavior.ts new file mode 100644 index 000000000..28e9b15f0 --- /dev/null +++ b/src/lib/terminal-behavior.ts @@ -0,0 +1,59 @@ +import type { ClientExtensionEntry } from '@shared/extension-types' + +export type ScrollInputPolicy = 'native' | 'fallbackToCursorKeysWhenAltScreenMouseCapture' + +export type ProviderTerminalBehavior = { + preferredRenderer?: 'canvas' + scrollInputPolicy: ScrollInputPolicy +} + +export type ScrollTranslationRuntime = { + altBufferActive: boolean + mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' +} + +const EXTENSION_BEHAVIOR_PROVIDERS = new Set(['opencode']) + +export function providerUsesExtensionTerminalBehavior(provider: string | undefined): boolean { + return typeof provider === 'string' && EXTENSION_BEHAVIOR_PROVIDERS.has(provider) +} + +export function getProviderTerminalBehavior( + provider: string | undefined, + extensions: ClientExtensionEntry[], +): ProviderTerminalBehavior { + const ext = extensions.find((entry) => entry.category === 'cli' && entry.name === provider) + + return { + preferredRenderer: ext?.cli?.terminalBehavior?.preferredRenderer, + scrollInputPolicy: ext?.cli?.terminalBehavior?.scrollInputPolicy ?? 'native', + } +} + +export function prefersCanvasRenderer( + provider: string | undefined, + extensions: ClientExtensionEntry[], +): boolean { + return getProviderTerminalBehavior(provider, extensions).preferredRenderer === 'canvas' +} + +export function shouldTranslateScrollToCursorKeys( + runtime: ScrollTranslationRuntime & { scrollInputPolicy: ScrollInputPolicy }, +): boolean { + return runtime.scrollInputPolicy === 'fallbackToCursorKeysWhenAltScreenMouseCapture' + && runtime.altBufferActive + && runtime.mouseTrackingMode !== 'none' +} + +export function scrollLinesToCursorKeys( + lines: number, + applicationCursorKeysMode: boolean, +): string | null { + if (lines === 0) return null + + const up = applicationCursorKeysMode ? '\u001bOA' : '\u001b[A' + const down = applicationCursorKeysMode ? '\u001bOB' : '\u001b[B' + const sequence = lines < 0 ? up : down + + return Array.from({ length: Math.abs(lines) }, () => sequence).join('') +} diff --git a/test/e2e/opencode-scroll-input-policy.test.tsx b/test/e2e/opencode-scroll-input-policy.test.tsx new file mode 100644 index 000000000..42cd883d0 --- /dev/null +++ b/test/e2e/opencode-scroll-input-policy.test.tsx @@ -0,0 +1,226 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientExtensionEntry } from '@shared/extension-types' +import TerminalView from '@/components/TerminalView' +import connectionReducer from '@/store/connectionSlice' +import extensionsReducer from '@/store/extensionsSlice' +import panesReducer from '@/store/panesSlice' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' + +const wsMocks = vi.hoisted(() => ({ + send: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn().mockReturnValue(() => {}), + onReconnect: vi.fn().mockReturnValue(() => {}), +})) + +let openElement: HTMLElement | null = null +let wheelHandler: ((event: WheelEvent) => boolean) | null = null + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMocks, +})) + +vi.mock('@/lib/terminal-themes', () => ({ + getTerminalTheme: () => ({}), +})) + +vi.mock('lucide-react', () => ({ + Loader2: ({ className }: { className?: string }) => , +})) + +vi.mock('@/components/terminal/terminal-runtime', () => ({ + createTerminalRuntime: () => ({ + attachAddons: vi.fn(), + fit: vi.fn(), + findNext: vi.fn(() => false), + findPrevious: vi.fn(() => false), + clearDecorations: vi.fn(), + onDidChangeResults: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + webglActive: vi.fn(() => false), + suspendWebgl: vi.fn(() => false), + resumeWebgl: vi.fn(), + }), +})) + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + options: Record = {} + cols = 80 + rows = 24 + buffer = { active: { type: 'alternate' as const } } + modes = { + applicationCursorKeysMode: false, + mouseTrackingMode: 'any' as const, + } + open = vi.fn((element: HTMLElement) => { + openElement = element + if (wheelHandler) { + element.addEventListener('wheel', wheelHandler as EventListener) + } + }) + loadAddon = vi.fn() + registerLinkProvider = vi.fn(() => ({ dispose: vi.fn() })) + write = vi.fn() + clear = vi.fn() + dispose = vi.fn() + onData = vi.fn() + onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) + attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn((handler: (event: WheelEvent) => boolean) => { + wheelHandler = handler + openElement?.addEventListener('wheel', handler as EventListener) + }) + getSelection = vi.fn(() => '') + focus = vi.fn() + selectAll = vi.fn() + reset = vi.fn() + scrollToBottom = vi.fn() + } + + return { Terminal: MockTerminal } +}) + +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +} + +function createStore(mode: TerminalPaneContent['mode'], extensions: ClientExtensionEntry[] = []) { + const tabId = `tab-${mode}` + const paneId = `pane-${mode}` + const terminalId = `term-${mode}` + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: `req-${mode}`, + status: 'running', + mode, + shell: 'system', + terminalId, + initialCwd: '/tmp', + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + extensions: extensionsReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode, + status: 'running', + title: 'Terminal', + titleSetByUser: false, + createRequestId: paneContent.createRequestId, + terminalId, + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' as const }, + connection: { + status: 'ready' as const, + platform: null, + availableClis: {}, + featureFlags: {}, + }, + extensions: { entries: extensions }, + }, + }) + + return { store, tabId, paneId, paneContent, terminalId } +} + +describe('opencode scroll input policy (e2e)', () => { + beforeEach(() => { + openElement = null + wheelHandler = null + wsMocks.send.mockClear() + vi.stubGlobal('ResizeObserver', MockResizeObserver) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('sends cursor-key input when an OpenCode pane receives wheel input in alt screen mouse mode', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(wheelHandler).not.toBeNull() + }) + + wsMocks.send.mockClear() + + fireEvent.wheel(getByTestId('terminal-xterm-container'), { deltaY: 24 }) + + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + })) + }) + + it('does not translate wheel input for non-opted-in providers', async () => { + const { store, tabId, paneId, paneContent } = createStore('shell') + + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(wheelHandler).not.toBeNull() + }) + + wsMocks.send.mockClear() + + fireEvent.wheel(getByTestId('terminal-xterm-container'), { deltaY: 24 }) + + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + }) +}) diff --git a/test/e2e/opencode-startup-probes.test.tsx b/test/e2e/opencode-startup-probes.test.tsx index 1c8c9121a..bdfdbebc2 100644 --- a/test/e2e/opencode-startup-probes.test.tsx +++ b/test/e2e/opencode-startup-probes.test.tsx @@ -115,6 +115,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() dispose = vi.fn() focus = vi.fn() getSelection = vi.fn(() => '') diff --git a/test/e2e/opencode-touch-scroll-input-policy.test.tsx b/test/e2e/opencode-touch-scroll-input-policy.test.tsx new file mode 100644 index 000000000..5dab864ca --- /dev/null +++ b/test/e2e/opencode-touch-scroll-input-policy.test.tsx @@ -0,0 +1,241 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientExtensionEntry } from '@shared/extension-types' +import TerminalView from '@/components/TerminalView' +import connectionReducer from '@/store/connectionSlice' +import extensionsReducer from '@/store/extensionsSlice' +import panesReducer from '@/store/panesSlice' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' + +const wsMocks = vi.hoisted(() => ({ + send: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn().mockReturnValue(() => {}), + onReconnect: vi.fn().mockReturnValue(() => {}), +})) + +let latestTerminal: { + scrollLines: ReturnType +} | null = null + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMocks, +})) + +vi.mock('@/lib/terminal-themes', () => ({ + getTerminalTheme: () => ({}), +})) + +vi.mock('lucide-react', () => ({ + Loader2: ({ className }: { className?: string }) => , +})) + +vi.mock('@/components/terminal/terminal-runtime', () => ({ + createTerminalRuntime: () => ({ + attachAddons: vi.fn(), + fit: vi.fn(), + findNext: vi.fn(() => false), + findPrevious: vi.fn(() => false), + clearDecorations: vi.fn(), + onDidChangeResults: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + webglActive: vi.fn(() => false), + suspendWebgl: vi.fn(() => false), + resumeWebgl: vi.fn(), + }), +})) + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + options: Record = {} + cols = 80 + rows = 24 + buffer = { active: { type: 'alternate' as const } } + modes = { + applicationCursorKeysMode: false, + mouseTrackingMode: 'any' as const, + } + open = vi.fn() + loadAddon = vi.fn() + registerLinkProvider = vi.fn(() => ({ dispose: vi.fn() })) + write = vi.fn() + clear = vi.fn() + dispose = vi.fn() + onData = vi.fn() + onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) + attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() + getSelection = vi.fn(() => '') + focus = vi.fn() + selectAll = vi.fn() + reset = vi.fn() + scrollToBottom = vi.fn() + scrollLines = vi.fn() + + constructor() { + latestTerminal = this + } + } + + return { Terminal: MockTerminal } +}) + +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +} + +function createStore(mode: TerminalPaneContent['mode'], extensions: ClientExtensionEntry[] = []) { + const tabId = `tab-${mode}` + const paneId = `pane-${mode}` + const terminalId = `term-${mode}` + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: `req-${mode}`, + status: 'running', + mode, + shell: 'system', + terminalId, + initialCwd: '/tmp', + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + extensions: extensionsReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode, + status: 'running', + title: 'Terminal', + titleSetByUser: false, + createRequestId: paneContent.createRequestId, + terminalId, + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' as const }, + connection: { + status: 'ready' as const, + platform: null, + availableClis: {}, + featureFlags: {}, + }, + extensions: { entries: extensions }, + }, + }) + + return { store, tabId, paneId, paneContent, terminalId } +} + +describe('opencode touch scroll input policy (e2e)', () => { + beforeEach(() => { + latestTerminal = null + wsMocks.send.mockClear() + vi.stubGlobal('ResizeObserver', MockResizeObserver) + ;(globalThis as any).setMobileForTest(true) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + ;(globalThis as any).setMobileForTest(false) + }) + + it('sends translated cursor-key input instead of local scrollback for OpenCode touch scrolling', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + })) + }) + + it('keeps native touch scrolling for non-opted-in providers', async () => { + const { store, tabId, paneId, paneContent } = createStore('shell') + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + expect(latestTerminal?.scrollLines).toHaveBeenCalledWith(1) + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + }) +}) diff --git a/test/e2e/pane-context-menu-stability.test.tsx b/test/e2e/pane-context-menu-stability.test.tsx index d3cfbe657..062bd8475 100644 --- a/test/e2e/pane-context-menu-stability.test.tsx +++ b/test/e2e/pane-context-menu-stability.test.tsx @@ -102,6 +102,7 @@ vi.mock('@xterm/xterm', () => { selectLines = vi.fn() paste = vi.fn() attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) diff --git a/test/e2e/tab-focus-behavior.test.tsx b/test/e2e/tab-focus-behavior.test.tsx index 8a9cf5fdf..09d7320d6 100644 --- a/test/e2e/tab-focus-behavior.test.tsx +++ b/test/e2e/tab-focus-behavior.test.tsx @@ -49,6 +49,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() constructor() { diff --git a/test/e2e/terminal-console-violations-regression.test.tsx b/test/e2e/terminal-console-violations-regression.test.tsx index 5fbc64cdf..33bcd5e21 100644 --- a/test/e2e/terminal-console-violations-regression.test.tsx +++ b/test/e2e/terminal-console-violations-regression.test.tsx @@ -93,6 +93,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() dispose = vi.fn() focus = vi.fn() getSelection = vi.fn(() => '') diff --git a/test/e2e/terminal-create-attach-ordering.test.tsx b/test/e2e/terminal-create-attach-ordering.test.tsx index 8cb0eaccb..e6965bdc4 100644 --- a/test/e2e/terminal-create-attach-ordering.test.tsx +++ b/test/e2e/terminal-create-attach-ordering.test.tsx @@ -102,6 +102,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() dispose = vi.fn() focus = vi.fn() getSelection = vi.fn(() => '') diff --git a/test/e2e/terminal-file-link-same-tab.test.tsx b/test/e2e/terminal-file-link-same-tab.test.tsx index cef43934b..6bb057b86 100644 --- a/test/e2e/terminal-file-link-same-tab.test.tsx +++ b/test/e2e/terminal-file-link-same-tab.test.tsx @@ -117,6 +117,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() paste = vi.fn() diff --git a/test/e2e/terminal-flaky-network-responsiveness.test.tsx b/test/e2e/terminal-flaky-network-responsiveness.test.tsx index 56e672ab4..853d8aa80 100644 --- a/test/e2e/terminal-flaky-network-responsiveness.test.tsx +++ b/test/e2e/terminal-flaky-network-responsiveness.test.tsx @@ -92,6 +92,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() dispose = vi.fn() focus = vi.fn() getSelection = vi.fn(() => '') diff --git a/test/e2e/terminal-mobile-viewport-flow.test.tsx b/test/e2e/terminal-mobile-viewport-flow.test.tsx index 0d1c4f26d..f44a93a8a 100644 --- a/test/e2e/terminal-mobile-viewport-flow.test.tsx +++ b/test/e2e/terminal-mobile-viewport-flow.test.tsx @@ -28,6 +28,7 @@ vi.mock('@xterm/xterm', () => ({ onData: vi.fn(() => ({ dispose: vi.fn() })), onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), attachCustomKeyEventHandler: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), write: vi.fn(), clear: vi.fn(), dispose: vi.fn(), diff --git a/test/e2e/terminal-osc52-policy-flow.test.tsx b/test/e2e/terminal-osc52-policy-flow.test.tsx index ad8af61f5..55a43b73b 100644 --- a/test/e2e/terminal-osc52-policy-flow.test.tsx +++ b/test/e2e/terminal-osc52-policy-flow.test.tsx @@ -82,6 +82,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() } diff --git a/test/e2e/terminal-paste-single-ingress.test.tsx b/test/e2e/terminal-paste-single-ingress.test.tsx index 1fcb4a880..74dabff64 100644 --- a/test/e2e/terminal-paste-single-ingress.test.tsx +++ b/test/e2e/terminal-paste-single-ingress.test.tsx @@ -60,6 +60,7 @@ vi.mock('@xterm/xterm', () => { attachCustomKeyEventHandler = vi.fn((cb: (event: KeyboardEvent) => boolean) => { keyHandler = cb }) + attachCustomWheelEventHandler = vi.fn() paste = vi.fn((text: string) => { onDataCb?.(text) }) diff --git a/test/e2e/terminal-search-flow.test.tsx b/test/e2e/terminal-search-flow.test.tsx index b8f4a65ef..5537ffa3c 100644 --- a/test/e2e/terminal-search-flow.test.tsx +++ b/test/e2e/terminal-search-flow.test.tsx @@ -69,6 +69,7 @@ vi.mock('@xterm/xterm', () => { attachCustomKeyEventHandler = vi.fn((cb: (event: KeyboardEvent) => boolean) => { keyHandler = cb }) + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() } diff --git a/test/e2e/terminal-settings-remount-scrollback.test.tsx b/test/e2e/terminal-settings-remount-scrollback.test.tsx index f42b08f43..54d0303a2 100644 --- a/test/e2e/terminal-settings-remount-scrollback.test.tsx +++ b/test/e2e/terminal-settings-remount-scrollback.test.tsx @@ -97,6 +97,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() dispose = vi.fn() focus = vi.fn() getSelection = vi.fn(() => '') diff --git a/test/e2e/terminal-url-context-menu.test.tsx b/test/e2e/terminal-url-context-menu.test.tsx index f905fa825..bb4445b4f 100644 --- a/test/e2e/terminal-url-context-menu.test.tsx +++ b/test/e2e/terminal-url-context-menu.test.tsx @@ -93,6 +93,7 @@ vi.mock('@xterm/xterm', () => { selectLines = vi.fn() paste = vi.fn() attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) diff --git a/test/e2e/terminal-url-link-click.test.tsx b/test/e2e/terminal-url-link-click.test.tsx index a45e13e1c..fc8b0b9b7 100644 --- a/test/e2e/terminal-url-link-click.test.tsx +++ b/test/e2e/terminal-url-link-click.test.tsx @@ -109,6 +109,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn(() => ({ dispose: vi.fn() })) onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() paste = vi.fn() diff --git a/test/e2e/turn-complete-notification-flow.test.tsx b/test/e2e/turn-complete-notification-flow.test.tsx index bf200bbd7..e74b4ac69 100644 --- a/test/e2e/turn-complete-notification-flow.test.tsx +++ b/test/e2e/turn-complete-notification-flow.test.tsx @@ -94,6 +94,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() selectAll = vi.fn() diff --git a/test/integration/extension-system.test.ts b/test/integration/extension-system.test.ts index e3a8de327..44854ce56 100644 --- a/test/integration/extension-system.test.ts +++ b/test/integration/extension-system.test.ts @@ -184,4 +184,31 @@ describe('Extension system integration', () => { // Attempting to start a client extension as a server should fail await expect(mgr.startServer('my-client-ext')).rejects.toThrow(/not.*server/i) }) + + it('preserves cli terminal behavior hints in the client registry', async () => { + await writeExtension(extDir, 'opencode', { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + command: 'opencode', + resumeArgs: ['--session', '{{sessionId}}'], + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, + }) + + mgr.scan([extDir]) + + const clientEntries = mgr.toClientRegistry() + expect(clientEntries).toHaveLength(1) + expect(clientEntries[0]?.cli?.terminalBehavior).toEqual({ + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }) + }) }) diff --git a/test/unit/client/components/App.ws-extensions.test.tsx b/test/unit/client/components/App.ws-extensions.test.tsx index e4753ae2f..03d6ac9c7 100644 --- a/test/unit/client/components/App.ws-extensions.test.tsx +++ b/test/unit/client/components/App.ws-extensions.test.tsx @@ -308,4 +308,57 @@ describe('App WS extension messages', () => { expect(store.getState().extensions.entries[0].serverRunning).toBe(false) expect(store.getState().extensions.entries[0].serverPort).toBeUndefined() }) + + it('clears stale extension metadata when ready reports a new server instance', async () => { + const store = createStore() + + render( + + + + ) + + await waitFor(() => { + expect(messageHandlers.size).toBeGreaterThan(0) + }) + + act(() => { + broadcastWs({ + type: 'ready', + timestamp: new Date().toISOString(), + serverInstanceId: 'srv-old', + }) + }) + + await waitFor(() => { + expect(store.getState().connection.serverInstanceId).toBe('srv-old') + }) + + const extensions: ClientExtensionEntry[] = [{ + name: 'test-ext', + version: '1.0.0', + label: 'Test Extension', + description: 'A test extension', + category: 'client', + }] + + act(() => { + broadcastWs({ type: 'extensions.registry', extensions }) + }) + + expect(store.getState().extensions.entries).toEqual(extensions) + + act(() => { + broadcastWs({ + type: 'ready', + timestamp: new Date().toISOString(), + serverInstanceId: 'srv-new', + }) + }) + + await waitFor(() => { + expect(store.getState().connection.serverInstanceId).toBe('srv-new') + expect(store.getState().extensions.entries).toEqual([]) + }) + }) }) diff --git a/test/unit/client/components/ExtensionPane.test.tsx b/test/unit/client/components/ExtensionPane.test.tsx index c9262a566..42b8192b9 100644 --- a/test/unit/client/components/ExtensionPane.test.tsx +++ b/test/unit/client/components/ExtensionPane.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event' import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import ExtensionPane from '@/components/panes/ExtensionPane' +import { resetEnsureExtensionsRegistryCacheForTests } from '@/hooks/useEnsureExtensionsRegistry' import extensionsReducer, { updateServerStatus } from '@/store/extensionsSlice' import type { ClientExtensionEntry } from '@shared/extension-types' import type { ExtensionPaneContent } from '@/store/paneTypes' @@ -50,10 +51,15 @@ afterEach(cleanup) describe('ExtensionPane', () => { beforeEach(() => { vi.clearAllMocks() + resetEnsureExtensionsRegistryCacheForTests() localStorage.clear() localStorage.setItem('freshell.auth-token', 'test-token') }) + afterEach(() => { + resetEnsureExtensionsRegistryCacheForTests() + }) + it('renders iframe with correct URL for a server extension', () => { const ext: ClientExtensionEntry = { name: 'my-dashboard', diff --git a/test/unit/client/components/TerminalView.keyboard.test.tsx b/test/unit/client/components/TerminalView.keyboard.test.tsx index 4cdc49ccf..beb3c9f3e 100644 --- a/test/unit/client/components/TerminalView.keyboard.test.tsx +++ b/test/unit/client/components/TerminalView.keyboard.test.tsx @@ -78,6 +78,7 @@ vi.mock('@xterm/xterm', () => { attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { capturedKeyHandler = handler }) + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => 'selected text') focus = vi.fn() diff --git a/test/unit/client/components/TerminalView.lastInputAt.test.tsx b/test/unit/client/components/TerminalView.lastInputAt.test.tsx index 24c3d41b3..8af9ff6f2 100644 --- a/test/unit/client/components/TerminalView.lastInputAt.test.tsx +++ b/test/unit/client/components/TerminalView.lastInputAt.test.tsx @@ -23,6 +23,7 @@ vi.mock('@xterm/xterm', () => ({ }), onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), attachCustomKeyEventHandler: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), dispose: vi.fn(), write: vi.fn(), writeln: vi.fn(), diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index 8dc6e6603..cc12ce50d 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -79,6 +79,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() constructor() { terminalInstances.push(this) } diff --git a/test/unit/client/components/TerminalView.linkWarning.test.tsx b/test/unit/client/components/TerminalView.linkWarning.test.tsx index ab28950f6..2b51c91e1 100644 --- a/test/unit/client/components/TerminalView.linkWarning.test.tsx +++ b/test/unit/client/components/TerminalView.linkWarning.test.tsx @@ -47,6 +47,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() selectAll = vi.fn() diff --git a/test/unit/client/components/TerminalView.mobile-viewport.test.tsx b/test/unit/client/components/TerminalView.mobile-viewport.test.tsx index bd507bcb3..5e21e81d3 100644 --- a/test/unit/client/components/TerminalView.mobile-viewport.test.tsx +++ b/test/unit/client/components/TerminalView.mobile-viewport.test.tsx @@ -53,6 +53,7 @@ vi.mock('@xterm/xterm', () => ({ onData: vi.fn(() => ({ dispose: vi.fn() })), onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), attachCustomKeyEventHandler: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), registerLinkProvider: vi.fn(() => ({ dispose: vi.fn() })), write: vi.fn(), clear: vi.fn(), diff --git a/test/unit/client/components/TerminalView.osc52.test.tsx b/test/unit/client/components/TerminalView.osc52.test.tsx index 5f7d5d90e..d21f0cc08 100644 --- a/test/unit/client/components/TerminalView.osc52.test.tsx +++ b/test/unit/client/components/TerminalView.osc52.test.tsx @@ -115,6 +115,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() constructor() { diff --git a/test/unit/client/components/TerminalView.renderer.test.tsx b/test/unit/client/components/TerminalView.renderer.test.tsx index 4a4893c8b..182bc09cb 100644 --- a/test/unit/client/components/TerminalView.renderer.test.tsx +++ b/test/unit/client/components/TerminalView.renderer.test.tsx @@ -6,7 +6,10 @@ import tabsReducer from '@/store/tabsSlice' import panesReducer from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' +import extensionsReducer, { setRegistry } from '@/store/extensionsSlice' import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import type { ClientExtensionEntry } from '@shared/extension-types' +import { resetEnsureExtensionsRegistryCacheForTests } from '@/hooks/useEnsureExtensionsRegistry' const wsMocks = vi.hoisted(() => ({ send: vi.fn(), @@ -15,6 +18,14 @@ const wsMocks = vi.hoisted(() => ({ onReconnect: vi.fn().mockReturnValue(() => {}), })) +const apiMocks = vi.hoisted(() => ({ + get: vi.fn(), +})) + +const authMocks = vi.hoisted(() => ({ + getAuthToken: vi.fn(() => undefined), +})) + const runtimeMockState = vi.hoisted(() => ({ throwOnAttach: false, lastEnableWebgl: null as boolean | null, @@ -38,6 +49,12 @@ vi.mock('@/lib/ws-client', () => ({ }), })) +vi.mock('@/lib/api', () => ({ + api: apiMocks, +})) + +vi.mock('@/lib/auth', () => authMocks) + vi.mock('@/lib/terminal-themes', () => ({ getTerminalTheme: () => ({}), })) @@ -102,6 +119,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() @@ -145,6 +163,7 @@ function createStore(renderer: 'auto' | 'webgl' | 'canvas', mode: TerminalPaneCo panes: panesReducer, settings: settingsReducer, connection: connectionReducer, + extensions: extensionsReducer, }, preloadedState: { tabs: { @@ -174,12 +193,27 @@ function createStore(renderer: 'auto' | 'webgl' | 'canvas', mode: TerminalPaneCo status: 'loaded', }, connection: { status: 'ready', error: null }, + extensions: { entries: [] }, }, }) return { store, tabId, paneId, paneContent, terminalId } } +const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +} + describe('TerminalView renderer mode', () => { beforeEach(() => { terminalInstances.length = 0 @@ -187,6 +221,10 @@ describe('TerminalView renderer mode', () => { runtimeMockState.throwOnAttach = false runtimeMockState.lastEnableWebgl = null runtimeMockState.lastRuntime = null + apiMocks.get.mockReset() + authMocks.getAuthToken.mockReset() + authMocks.getAuthToken.mockReturnValue(undefined) + resetEnsureExtensionsRegistryCacheForTests() wsMocks.send.mockClear() wsMocks.send.mockImplementation((msg: any) => { if ( @@ -208,6 +246,7 @@ describe('TerminalView renderer mode', () => { afterEach(() => { cleanup() + resetEnsureExtensionsRegistryCacheForTests() vi.unstubAllGlobals() messageHandler = null }) @@ -225,7 +264,7 @@ describe('TerminalView renderer mode', () => { }) }) - it('auto mode keeps OpenCode on canvas by default', async () => { + it('auto mode does not force canvas for opencode without a behavior hint', async () => { const { store, tabId, paneId, paneContent } = createStore('auto', 'opencode') render( @@ -234,6 +273,38 @@ describe('TerminalView renderer mode', () => { ) await waitFor(() => { + expect(runtimeMockState.lastEnableWebgl).toBe(true) + }) + }) + + it('auto mode keeps OpenCode on canvas when the behavior hint prefers it', async () => { + const { store, tabId, paneId, paneContent } = createStore('auto', 'opencode') + store.dispatch(setRegistry([opencodeExtensionWithBehaviorHint])) + + render( + + + , + ) + + await waitFor(() => { + expect(runtimeMockState.lastEnableWebgl).toBe(false) + }) + }) + + it('waits for extension metadata before choosing the auto renderer for opencode panes', async () => { + authMocks.getAuthToken.mockReturnValue('token-test') + apiMocks.get.mockResolvedValue([opencodeExtensionWithBehaviorHint]) + const { store, tabId, paneId, paneContent } = createStore('auto', 'opencode') + + render( + + + , + ) + + await waitFor(() => { + expect(apiMocks.get).toHaveBeenCalled() expect(runtimeMockState.lastEnableWebgl).toBe(false) }) }) diff --git a/test/unit/client/components/TerminalView.resumeSession.test.tsx b/test/unit/client/components/TerminalView.resumeSession.test.tsx index a941b840e..070890270 100644 --- a/test/unit/client/components/TerminalView.resumeSession.test.tsx +++ b/test/unit/client/components/TerminalView.resumeSession.test.tsx @@ -49,6 +49,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() } diff --git a/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx b/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx new file mode 100644 index 000000000..06a362dc8 --- /dev/null +++ b/test/unit/client/components/TerminalView.scroll-input-policy.test.tsx @@ -0,0 +1,317 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientExtensionEntry } from '@shared/extension-types' +import TerminalView from '@/components/TerminalView' +import { resetEnsureExtensionsRegistryCacheForTests } from '@/hooks/useEnsureExtensionsRegistry' +import connectionReducer from '@/store/connectionSlice' +import extensionsReducer, { setRegistry } from '@/store/extensionsSlice' +import panesReducer from '@/store/panesSlice' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' + +const wsMocks = vi.hoisted(() => ({ + send: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn().mockReturnValue(() => {}), + onReconnect: vi.fn().mockReturnValue(() => {}), +})) + +const apiMocks = vi.hoisted(() => ({ + get: vi.fn(), +})) + +const authMocks = vi.hoisted(() => ({ + getAuthToken: vi.fn(() => undefined), +})) + +let wheelHandler: ((event: WheelEvent) => boolean) | null = null + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMocks, +})) + +vi.mock('@/lib/api', () => ({ + api: apiMocks, +})) + +vi.mock('@/lib/auth', () => authMocks) + +vi.mock('@/lib/terminal-themes', () => ({ + getTerminalTheme: () => ({}), +})) + +vi.mock('lucide-react', () => ({ + Loader2: ({ className }: { className?: string }) => , +})) + +vi.mock('@/components/terminal/terminal-runtime', () => ({ + createTerminalRuntime: () => ({ + attachAddons: vi.fn(), + fit: vi.fn(), + findNext: vi.fn(() => false), + findPrevious: vi.fn(() => false), + clearDecorations: vi.fn(), + onDidChangeResults: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + webglActive: vi.fn(() => false), + suspendWebgl: vi.fn(() => false), + resumeWebgl: vi.fn(), + }), +})) + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + options: Record = {} + cols = 80 + rows = 24 + buffer = { active: { type: 'alternate' as const } } + modes = { + applicationCursorKeysMode: false, + mouseTrackingMode: 'any' as const, + } + open = vi.fn() + loadAddon = vi.fn() + registerLinkProvider = vi.fn(() => ({ dispose: vi.fn() })) + write = vi.fn() + clear = vi.fn() + dispose = vi.fn() + onData = vi.fn() + onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) + attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn((handler: (event: WheelEvent) => boolean) => { + wheelHandler = handler + }) + getSelection = vi.fn(() => '') + focus = vi.fn() + selectAll = vi.fn() + reset = vi.fn() + scrollToBottom = vi.fn() + } + + return { Terminal: MockTerminal } +}) + +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +} + +function createStore( + mode: TerminalPaneContent['mode'], + extensions: ClientExtensionEntry[] = [], + terminalId = `term-${mode}`, +) { + const tabId = `tab-${mode}` + const paneId = `pane-${mode}` + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: `req-${mode}`, + status: 'running', + mode, + shell: 'system', + terminalId, + initialCwd: '/tmp', + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + extensions: extensionsReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode, + status: 'running', + title: 'Terminal', + titleSetByUser: false, + createRequestId: paneContent.createRequestId, + terminalId, + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' as const }, + connection: { + status: 'ready' as const, + platform: null, + availableClis: {}, + featureFlags: {}, + }, + extensions: { entries: extensions }, + }, + }) + + return { store, tabId, paneId, paneContent, terminalId } +} + +describe('TerminalView wheel scroll input policy', () => { + beforeEach(() => { + wheelHandler = null + apiMocks.get.mockReset() + authMocks.getAuthToken.mockReset() + authMocks.getAuthToken.mockReturnValue(undefined) + resetEnsureExtensionsRegistryCacheForTests() + wsMocks.send.mockClear() + vi.stubGlobal('ResizeObserver', MockResizeObserver) + }) + + afterEach(() => { + cleanup() + resetEnsureExtensionsRegistryCacheForTests() + vi.unstubAllGlobals() + }) + + it('loads the extension registry for coding-cli panes before applying wheel translation', async () => { + authMocks.getAuthToken.mockReturnValue('token-test') + apiMocks.get.mockResolvedValue([opencodeExtensionWithBehaviorHint]) + const { store, tabId, paneId, paneContent } = createStore('opencode', [], 'term-opencode') + + render( + + + , + ) + + await waitFor(() => { + expect(apiMocks.get).toHaveBeenCalled() + expect(wheelHandler).not.toBeNull() + }) + + wsMocks.send.mockClear() + + const event = new WheelEvent('wheel', { deltaY: 24, cancelable: true }) + expect(wheelHandler?.(event)).toBe(false) + expect(wsMocks.send).toHaveBeenCalledWith({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + }) + }) + + it('translates wheel scrolling into cursor-key input for opted-in providers', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint], 'term-opencode') + + render( + + + , + ) + + await waitFor(() => { + expect(wheelHandler).not.toBeNull() + }) + + wsMocks.send.mockClear() + + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const event = { + deltaY: 24, + preventDefault, + stopPropagation, + } as unknown as WheelEvent + expect(wheelHandler?.(event)).toBe(false) + expect(preventDefault).toHaveBeenCalledOnce() + expect(stopPropagation).toHaveBeenCalledOnce() + expect(wsMocks.send).toHaveBeenCalledWith({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + }) + }) + + it('keeps non-opted-in providers on native wheel behavior', async () => { + authMocks.getAuthToken.mockReturnValue('token-test') + apiMocks.get.mockImplementation(() => new Promise(() => {})) + const { store, tabId, paneId, paneContent } = createStore('codex', [], 'term-codex') + + render( + + + , + ) + + await waitFor(() => { + expect(apiMocks.get).not.toHaveBeenCalled() + expect(wheelHandler).not.toBeNull() + }) + + wsMocks.send.mockClear() + + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const event = { + deltaY: 24, + preventDefault, + stopPropagation, + } as unknown as WheelEvent + expect(wheelHandler?.(event)).toBe(true) + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input' })) + }) + + it('updates the registered wheel handler when the extension registry hydrates later', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [], 'term-opencode') + + render( + + + , + ) + + await waitFor(() => { + expect(wheelHandler).not.toBeNull() + }) + + const event = new WheelEvent('wheel', { deltaY: 24, cancelable: true }) + + wsMocks.send.mockClear() + expect(wheelHandler?.(event)).toBe(true) + expect(wsMocks.send).not.toHaveBeenCalled() + + act(() => { + store.dispatch(setRegistry([opencodeExtensionWithBehaviorHint])) + }) + + wsMocks.send.mockClear() + expect(wheelHandler?.(event)).toBe(false) + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + })) + }) +}) diff --git a/test/unit/client/components/TerminalView.search.test.tsx b/test/unit/client/components/TerminalView.search.test.tsx index 3a0b5a3b5..acf22f2cb 100644 --- a/test/unit/client/components/TerminalView.search.test.tsx +++ b/test/unit/client/components/TerminalView.search.test.tsx @@ -80,6 +80,7 @@ vi.mock('@xterm/xterm', () => { attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { capturedKeyHandler = handler }) + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() diff --git a/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx new file mode 100644 index 000000000..d1f92c682 --- /dev/null +++ b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx @@ -0,0 +1,212 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientExtensionEntry } from '@shared/extension-types' +import TerminalView from '@/components/TerminalView' +import connectionReducer from '@/store/connectionSlice' +import extensionsReducer from '@/store/extensionsSlice' +import panesReducer from '@/store/panesSlice' +import type { PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' + +const wsMocks = vi.hoisted(() => ({ + send: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + onMessage: vi.fn().mockReturnValue(() => {}), + onReconnect: vi.fn().mockReturnValue(() => {}), +})) + +let latestTerminal: { + scrollLines: ReturnType +} | null = null + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => wsMocks, +})) + +vi.mock('@/lib/terminal-themes', () => ({ + getTerminalTheme: () => ({}), +})) + +vi.mock('lucide-react', () => ({ + Loader2: ({ className }: { className?: string }) => , +})) + +vi.mock('@/components/terminal/terminal-runtime', () => ({ + createTerminalRuntime: () => ({ + attachAddons: vi.fn(), + fit: vi.fn(), + findNext: vi.fn(() => false), + findPrevious: vi.fn(() => false), + clearDecorations: vi.fn(), + onDidChangeResults: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + webglActive: vi.fn(() => false), + suspendWebgl: vi.fn(() => false), + resumeWebgl: vi.fn(), + }), +})) + +vi.mock('@xterm/xterm', () => { + class MockTerminal { + options: Record = {} + cols = 80 + rows = 24 + buffer = { active: { type: 'alternate' as const } } + modes = { + applicationCursorKeysMode: false, + mouseTrackingMode: 'any' as const, + } + open = vi.fn() + loadAddon = vi.fn() + registerLinkProvider = vi.fn(() => ({ dispose: vi.fn() })) + write = vi.fn() + clear = vi.fn() + dispose = vi.fn() + onData = vi.fn() + onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) + attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() + getSelection = vi.fn(() => '') + focus = vi.fn() + selectAll = vi.fn() + reset = vi.fn() + scrollToBottom = vi.fn() + scrollLines = vi.fn() + + constructor() { + latestTerminal = this + } + } + + return { Terminal: MockTerminal } +}) + +vi.mock('@xterm/xterm/css/xterm.css', () => ({})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +const opencodeExtensionWithBehaviorHint: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +} + +function createStore(mode: TerminalPaneContent['mode'], extensions: ClientExtensionEntry[]) { + const tabId = `tab-${mode}` + const paneId = `pane-${mode}` + const terminalId = `term-${mode}` + + const paneContent: TerminalPaneContent = { + kind: 'terminal', + createRequestId: `req-${mode}`, + status: 'running', + mode, + shell: 'system', + terminalId, + initialCwd: '/tmp', + } + + const root: PaneNode = { type: 'leaf', id: paneId, content: paneContent } + + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + extensions: extensionsReducer, + }, + preloadedState: { + tabs: { + tabs: [{ + id: tabId, + mode, + status: 'running', + title: 'Terminal', + titleSetByUser: false, + createRequestId: paneContent.createRequestId, + terminalId, + }], + activeTabId: tabId, + }, + panes: { + layouts: { [tabId]: root }, + activePane: { [tabId]: paneId }, + paneTitles: {}, + }, + settings: { settings: defaultSettings, status: 'loaded' as const }, + connection: { + status: 'ready' as const, + platform: null, + availableClis: {}, + featureFlags: {}, + }, + extensions: { entries: extensions }, + }, + }) + + return { store, tabId, paneId, paneContent, terminalId } +} + +describe('TerminalView touch scroll input policy', () => { + beforeEach(() => { + latestTerminal = null + wsMocks.send.mockClear() + vi.stubGlobal('ResizeObserver', MockResizeObserver) + ;(globalThis as any).setMobileForTest(true) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + ;(globalThis as any).setMobileForTest(false) + }) + + it('translates touch scrolling into cursor-key input for opted-in providers', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + // 20px exceeds the current 18px-per-line threshold, so this should emit one down-arrow sequence. + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + terminalId: 'term-opencode', + data: '\u001b[B', + })) + }) +}) diff --git a/test/unit/client/components/TerminalView.urlClick.test.tsx b/test/unit/client/components/TerminalView.urlClick.test.tsx index 0a13eaa5a..44dea194a 100644 --- a/test/unit/client/components/TerminalView.urlClick.test.tsx +++ b/test/unit/client/components/TerminalView.urlClick.test.tsx @@ -62,6 +62,7 @@ vi.mock('@xterm/xterm', () => { onData = vi.fn() onTitleChange = vi.fn(() => ({ dispose: vi.fn() })) attachCustomKeyEventHandler = vi.fn() + attachCustomWheelEventHandler = vi.fn() getSelection = vi.fn(() => '') focus = vi.fn() selectAll = vi.fn() diff --git a/test/unit/client/components/TerminalView.visibility.test.tsx b/test/unit/client/components/TerminalView.visibility.test.tsx index 5aba97072..c7234eb2d 100644 --- a/test/unit/client/components/TerminalView.visibility.test.tsx +++ b/test/unit/client/components/TerminalView.visibility.test.tsx @@ -36,6 +36,7 @@ vi.mock('@xterm/xterm', () => ({ onData: vi.fn(() => ({ dispose: vi.fn() })), onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), attachCustomKeyEventHandler: vi.fn(), + attachCustomWheelEventHandler: vi.fn(), dispose: vi.fn(), write: vi.fn(), clear: vi.fn(), diff --git a/test/unit/client/components/panes/PanePicker.test.tsx b/test/unit/client/components/panes/PanePicker.test.tsx index 70e7e1878..d61bf0746 100644 --- a/test/unit/client/components/panes/PanePicker.test.tsx +++ b/test/unit/client/components/panes/PanePicker.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-li import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import PanePicker from '@/components/panes/PanePicker' +import { resetEnsureExtensionsRegistryCacheForTests } from '@/hooks/useEnsureExtensionsRegistry' import { setStatus } from '@/store/connectionSlice' import settingsReducer from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' @@ -140,12 +141,14 @@ describe('PanePicker', () => { beforeEach(() => { vi.clearAllMocks() mockApiGet.mockReset() + resetEnsureExtensionsRegistryCacheForTests() localStorage.clear() localStorage.setItem('freshell.auth-token', 'test-token') }) afterEach(() => { cleanup() + resetEnsureExtensionsRegistryCacheForTests() }) describe('rendering', () => { diff --git a/test/unit/client/hooks/useEnsureExtensionsRegistry.test.tsx b/test/unit/client/hooks/useEnsureExtensionsRegistry.test.tsx new file mode 100644 index 000000000..3be0c2f38 --- /dev/null +++ b/test/unit/client/hooks/useEnsureExtensionsRegistry.test.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' +import { Provider } from 'react-redux' +import connectionReducer, { setServerInstanceId } from '@/store/connectionSlice' +import extensionsReducer from '@/store/extensionsSlice' +import { resetEnsureExtensionsRegistryCacheForTests, useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' + +const apiMocks = vi.hoisted(() => ({ + get: vi.fn(), +})) + +const authMocks = vi.hoisted(() => ({ + getAuthToken: vi.fn(() => 'token-test'), +})) + +vi.mock('@/lib/api', () => ({ + api: apiMocks, +})) + +vi.mock('@/lib/auth', () => authMocks) + +vi.mock('@/lib/client-logger', () => ({ + createLogger: () => ({ + warn: vi.fn(), + }), +})) + +function createDeferred() { + let resolve: ((value: T) => void) | null = null + let reject: ((reason?: unknown) => void) | null = null + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { + promise, + reject: (reason?: unknown) => reject?.(reason), + resolve: (value: T) => resolve?.(value), + } +} + +function createStore(serverInstanceId = 'server-a') { + return configureStore({ + reducer: { + connection: connectionReducer, + extensions: extensionsReducer, + }, + preloadedState: { + connection: { + status: 'ready' as const, + platform: null, + availableClis: {}, + featureFlags: {}, + serverInstanceId, + }, + extensions: { + entries: [], + }, + }, + }) +} + +function createWrapper(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useEnsureExtensionsRegistry', () => { + beforeEach(() => { + apiMocks.get.mockReset() + authMocks.getAuthToken.mockReset() + authMocks.getAuthToken.mockReturnValue('token-test') + resetEnsureExtensionsRegistryCacheForTests() + }) + + afterEach(() => { + cleanup() + resetEnsureExtensionsRegistryCacheForTests() + }) + + it('deduplicates concurrent registry loads across multiple consumers', async () => { + const deferred = createDeferred>() + apiMocks.get.mockReturnValue(deferred.promise) + const store = createStore() + const wrapper = createWrapper(store) + + const first = renderHook(() => useEnsureExtensionsRegistry(), { wrapper }) + const second = renderHook(() => useEnsureExtensionsRegistry(), { wrapper }) + + await waitFor(() => { + expect(apiMocks.get).toHaveBeenCalledTimes(1) + expect(first.result.current).toBe(false) + expect(second.result.current).toBe(false) + }) + + deferred.resolve([{ + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + }]) + + await waitFor(() => { + expect(first.result.current).toBe(true) + expect(second.result.current).toBe(true) + expect(store.getState().extensions.entries).toHaveLength(1) + }) + }) + + it('starts a fresh load after the server instance changes during an in-flight request', async () => { + const deferred = createDeferred>() + apiMocks.get + .mockReturnValueOnce(deferred.promise) + .mockResolvedValueOnce([{ + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + }]) + const store = createStore('server-a') + const wrapper = createWrapper(store) + + const { result } = renderHook(() => useEnsureExtensionsRegistry(), { wrapper }) + + await waitFor(() => { + expect(apiMocks.get).toHaveBeenCalledTimes(1) + expect(result.current).toBe(false) + }) + + act(() => { + store.dispatch(setServerInstanceId('server-b')) + }) + + await waitFor(() => { + expect(apiMocks.get).toHaveBeenCalledTimes(2) + expect(result.current).toBe(true) + expect(store.getState().extensions.entries).toHaveLength(1) + }) + + deferred.resolve([]) + }) +}) diff --git a/test/unit/client/lib/terminal-behavior.test.ts b/test/unit/client/lib/terminal-behavior.test.ts new file mode 100644 index 000000000..74305251d --- /dev/null +++ b/test/unit/client/lib/terminal-behavior.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { ClientExtensionEntry } from '@shared/extension-types' +import { + getProviderTerminalBehavior, + prefersCanvasRenderer, + providerUsesExtensionTerminalBehavior, + scrollLinesToCursorKeys, + shouldTranslateScrollToCursorKeys, +} from '@/lib/terminal-behavior' + +const extensions: ClientExtensionEntry[] = [{ + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, +}] + +describe('terminal behavior', () => { + it('returns provider terminal behavior from the extension registry', () => { + expect(getProviderTerminalBehavior('opencode', extensions)).toEqual({ + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }) + }) + + it('keeps non-opted-in providers native by default', () => { + expect(getProviderTerminalBehavior('codex', extensions)).toEqual({ + preferredRenderer: undefined, + scrollInputPolicy: 'native', + }) + }) + + it('requires both alt screen and mouse capture before translating scroll', () => { + expect(shouldTranslateScrollToCursorKeys({ + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + altBufferActive: true, + mouseTrackingMode: 'any', + })).toBe(true) + expect(shouldTranslateScrollToCursorKeys({ + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + altBufferActive: false, + mouseTrackingMode: 'any', + })).toBe(false) + }) + + it('builds repeated cursor-key sequences for positive and negative line counts', () => { + expect(scrollLinesToCursorKeys(-2, false)).toBe('\u001b[A\u001b[A') + expect(scrollLinesToCursorKeys(3, true)).toBe('\u001bOB\u001bOB\u001bOB') + }) + + it('maps canvas renderer preference without hard-coded provider checks', () => { + expect(prefersCanvasRenderer('opencode', extensions)).toBe(true) + expect(prefersCanvasRenderer('codex', extensions)).toBe(false) + }) + + it('only waits for extension-managed behavior on opted-in providers', () => { + expect(providerUsesExtensionTerminalBehavior('opencode')).toBe(true) + expect(providerUsesExtensionTerminalBehavior('codex')).toBe(false) + expect(providerUsesExtensionTerminalBehavior('claude')).toBe(false) + }) +})