Fix inactive terminal bar cursor rendering (#2058)

* Fix inactive terminal bar cursor rendering

Co-authored-by: Orca <help@stably.ai>

* Test inactive cursor over prompt glyph

Co-authored-by: Orca <help@stably.ai>

* Test inactive prompt cursor rendering

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-16 03:05:50 -04:00 committed by GitHub
parent 77264240c8
commit 86192ef53d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 207 additions and 4 deletions

View File

@ -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

View File

@ -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')
})
})

View File

@ -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,

View File

@ -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

View File

@ -1,10 +1,24 @@
import type { ITerminalOptions } from '@xterm/xterm'
type TerminalCursorStyle = NonNullable<ITerminalOptions['cursorStyle']>
type TerminalCursorInactiveStyle = NonNullable<ITerminalOptions['cursorInactiveStyle']>
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:

View File

@ -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<void> {
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<InactiveCursorRender> {
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<HTMLElement>('.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')
})
})