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:
parent
77264240c8
commit
86192ef53d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue