diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 570dc848e..7985d67d4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,7 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' @@ -199,7 +200,9 @@ export function applyTerminalAppearance( // bleeding in from a prior opacity setting that has since been reset. pane.terminal.options.allowTransparency = settings.terminalBackgroundOpacity !== undefined && settings.terminalBackgroundOpacity < 1 - pane.terminal.options.cursorStyle = settings.terminalCursorStyle + const cursorStyle = settings.terminalCursorStyle ?? 'bar' + pane.terminal.options.cursorStyle = cursorStyle + pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle) pane.terminal.options.cursorBlink = settings.terminalCursorBlink const paneSize = paneFontSizes.get(pane.id) pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize diff --git a/src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts b/src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts new file mode 100644 index 000000000..c2374c770 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cursor-inactive-style.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import { applyTerminalAppearance } from './terminal-appearance' + +describe('terminal inactive cursor style', () => { + it('keeps blurred non-block cursors from rendering as an outline box', () => { + const options = { + cursorStyle: 'block' as const, + cursorInactiveStyle: 'outline' as const + } + const terminal = { + options, + cols: 80, + rows: 24 + } as unknown as ManagedPane['terminal'] + const pane = { id: 1, terminal } as ManagedPane + const manager = { + getPanes: () => [pane], + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + const settings = { + ...getDefaultSettings('/tmp'), + terminalCursorStyle: 'bar' as const + } + + applyTerminalAppearance( + manager, + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + expect(options.cursorStyle).toBe('bar') + expect(options.cursorInactiveStyle).toBe('bar') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 976e519e6..e67388328 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { useAppStore } from '@/store' import { createFilePathLinkProvider, @@ -741,6 +742,7 @@ export function useTerminalPaneLifecycle({ terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) + const cursorStyle = currentSettings?.terminalCursorStyle ?? 'bar' return { fontSize: currentSettings?.terminalFontSize ?? 14, fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''), @@ -753,7 +755,8 @@ export function useTerminalPaneLifecycle({ Math.round((currentSettings?.terminalScrollbackBytes ?? 10_000_000) / 200) ) ), - cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar', + cursorStyle, + cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle), cursorBlink: currentSettings?.terminalCursorBlink ?? true, macOptionIsMeta: effectiveMacOptionAsAltRef.current === 'true', lineHeight: currentSettings?.terminalLineHeight ?? 1, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index f384b1ed8..158a8599d 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -7,7 +7,10 @@ import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' import { openTerminal } from './pane-lifecycle' -import { buildDefaultTerminalOptions } from './pane-terminal-options' +import { + buildDefaultTerminalOptions, + resolveTerminalCursorInactiveStyle +} from './pane-terminal-options' const webglMock = vi.hoisted(() => ({ contextLossHandler: null as (() => void) | null, @@ -66,6 +69,16 @@ describe('buildDefaultTerminalOptions', () => { expect(buildDefaultTerminalOptions().macOptionIsMeta).toBe(false) }) + it('keeps the default inactive cursor as a single bar', () => { + expect(buildDefaultTerminalOptions().cursorInactiveStyle).toBe('bar') + }) + + it('only uses inactive outline for block cursors', () => { + expect(resolveTerminalCursorInactiveStyle('block')).toBe('outline') + expect(resolveTerminalCursorInactiveStyle('bar')).toBe('bar') + expect(resolveTerminalCursorInactiveStyle('underline')).toBe('underline') + }) + it('advertises kitty keyboard protocol so CLIs enable enhanced key reporting', () => { // Why: Orca already writes CSI-u bytes for extended key chords like // Shift+Enter (see terminal-shortcut-policy.ts). CLIs that gate diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 27e7c802e..b7e935600 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -1,10 +1,24 @@ import type { ITerminalOptions } from '@xterm/xterm' +type TerminalCursorStyle = NonNullable +type TerminalCursorInactiveStyle = NonNullable + +export function resolveTerminalCursorInactiveStyle( + cursorStyle: TerminalCursorStyle | undefined +): TerminalCursorInactiveStyle { + // Why: xterm's default inactive outline turns a bar/underline cursor into + // extra strokes in blurred panes; only block cursors benefit from outline. + return (cursorStyle ?? 'bar') === 'block' ? 'outline' : (cursorStyle ?? 'bar') +} + export function buildDefaultTerminalOptions(): ITerminalOptions { + const cursorStyle: TerminalCursorStyle = 'bar' + return { allowProposedApi: true, cursorBlink: true, - cursorStyle: 'bar', + cursorStyle, + cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle), fontSize: 14, // Cross-platform fallback chain; keep in sync with FALLBACK_FONTS in layout-serialization.ts. fontFamily: diff --git a/tests/e2e/terminal-cursor-inactive-style.spec.ts b/tests/e2e/terminal-cursor-inactive-style.spec.ts new file mode 100644 index 000000000..9ccb32c5c --- /dev/null +++ b/tests/e2e/terminal-cursor-inactive-style.spec.ts @@ -0,0 +1,128 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +type InactiveCursorRender = { + cursorStyle: unknown + cursorInactiveStyle: unknown + terminalFocused: boolean + cursorClassName: string +} + +type XtermCursorInactiveStyle = 'outline' | 'block' | 'bar' | 'underline' | 'none' + +async function placeInactiveCursorAtPrompt(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + const tabId = worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? state.activeTabId) + : state.activeTabId + if (!tabId) { + throw new Error('No active terminal tab') + } + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error('Active terminal PaneManager is not mounted') + } + const panes = manager.getPanes?.() ?? [] + const activePane = manager.getActivePane?.() ?? panes.at(-1) ?? null + const inactivePane = panes.find((pane) => pane.id !== activePane?.id) ?? null + if (!inactivePane || !activePane) { + throw new Error('Need a split inactive pane to position the cursor') + } + + manager.setActivePane(activePane.id, { focus: true }) + inactivePane.terminal.write('\r\n$ ') + inactivePane.terminal.blur() + inactivePane.terminal.refresh(0, inactivePane.terminal.rows - 1) + }) +} + +async function renderInactiveCursor( + page: Page, + forcedInactiveStyle?: XtermCursorInactiveStyle +): Promise { + return page.evaluate(async (forcedInactiveStyle) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + const tabId = worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? state.activeTabId) + : state.activeTabId + if (!tabId) { + throw new Error('No active terminal tab') + } + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error('Active terminal PaneManager is not mounted') + } + const panes = manager.getPanes?.() ?? [] + const activePane = manager.getActivePane?.() ?? panes.at(-1) ?? null + const inactivePane = panes.find((pane) => pane.id !== activePane?.id) ?? null + if (!inactivePane || !activePane) { + throw new Error('Need a split inactive pane to inspect cursor rendering') + } + + manager.setActivePane(activePane.id, { focus: true }) + if (forcedInactiveStyle) { + inactivePane.terminal.options.cursorInactiveStyle = forcedInactiveStyle + } + inactivePane.terminal.blur() + inactivePane.terminal.refresh(0, inactivePane.terminal.rows - 1) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + + const terminalCore = inactivePane.terminal as unknown as { + _core?: { _coreBrowserService?: { isFocused?: boolean } } + } + const cursor = inactivePane.container.querySelector('.xterm-cursor') + return { + cursorStyle: inactivePane.terminal.options.cursorStyle, + cursorInactiveStyle: inactivePane.terminal.options.cursorInactiveStyle, + terminalFocused: terminalCore._core?._coreBrowserService?.isFocused ?? true, + cursorClassName: + cursor?.className ?? + `(canvas renderer: ${inactivePane.terminal.options.cursorInactiveStyle})` + } + }, forcedInactiveStyle) +} + +test.describe('Terminal inactive cursor rendering', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 1, 30_000) + }) + + test('keeps an unfocused prompt cursor rendered as one bar', async ({ orcaPage }) => { + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2) + await placeInactiveCursorAtPrompt(orcaPage) + + const fixedBehavior = await renderInactiveCursor(orcaPage) + expect(fixedBehavior.terminalFocused).toBe(false) + expect(fixedBehavior.cursorStyle).toBe('bar') + expect(fixedBehavior.cursorInactiveStyle).toBe('bar') + expect(fixedBehavior.cursorClassName).toContain('bar') + expect(fixedBehavior.cursorClassName).not.toContain('xterm-cursor-outline') + + const oldBehavior = await renderInactiveCursor(orcaPage, 'outline') + expect(oldBehavior.terminalFocused).toBe(false) + expect(oldBehavior.cursorStyle).toBe('bar') + expect(oldBehavior.cursorInactiveStyle).toBe('outline') + }) +})