mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix: reduce Gemini terminal flashing (#203)
This commit is contained in:
parent
6bfe7aaaeb
commit
ba7634ed0a
8 changed files with 125 additions and 12 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { isGeminiTerminalTitle } from '@/lib/agent-status'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { createIpcPtyTransport } from './pty-transport'
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ export function connectPanePty(
|
|||
): void {
|
||||
const onExit = (ptyId: string): void => {
|
||||
deps.clearTabPtyId(deps.tabId, ptyId)
|
||||
manager.setPaneGpuRendering(pane.id, true)
|
||||
const panes = manager.getPanes()
|
||||
if (panes.length <= 1) {
|
||||
deps.onPtyExitRef.current(ptyId)
|
||||
|
|
@ -31,7 +33,8 @@ export function connectPanePty(
|
|||
manager.closePane(pane.id)
|
||||
}
|
||||
|
||||
const onTitleChange = (title: string): void => {
|
||||
const onTitleChange = (title: string, rawTitle: string): void => {
|
||||
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
|
||||
deps.updateTabTitle(deps.tabId, title)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker
|
||||
createAgentStatusTracker,
|
||||
normalizeTerminalTitle
|
||||
} from '@/lib/agent-status'
|
||||
|
||||
export type PtyTransport = {
|
||||
|
|
@ -65,7 +66,7 @@ export function extractLastOscTitle(data: string): string | null {
|
|||
export type IpcPtyTransportOptions = {
|
||||
cwd?: string
|
||||
onPtyExit?: (ptyId: string) => void
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChange?: (title: string, rawTitle: string) => void
|
||||
onPtySpawn?: (ptyId: string) => void
|
||||
onBell?: () => void
|
||||
onAgentBecameIdle?: () => void
|
||||
|
|
@ -132,8 +133,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
|||
clearTimeout(staleTitleTimer)
|
||||
staleTitleTimer = null
|
||||
}
|
||||
lastEmittedTitle = title
|
||||
onTitleChange(title)
|
||||
lastEmittedTitle = normalizeTerminalTitle(title)
|
||||
onTitleChange(lastEmittedTitle, title)
|
||||
agentTracker?.handleTitle(title)
|
||||
} else if (
|
||||
lastEmittedTitle &&
|
||||
|
|
@ -152,7 +153,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
|||
) {
|
||||
const cleared = clearWorkingIndicators(lastEmittedTitle)
|
||||
lastEmittedTitle = cleared
|
||||
onTitleChange(cleared)
|
||||
onTitleChange(cleared, cleared)
|
||||
}
|
||||
}, STALE_TITLE_TIMEOUT)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
|||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker
|
||||
createAgentStatusTracker,
|
||||
isGeminiTerminalTitle,
|
||||
normalizeTerminalTitle
|
||||
} from './agent-status'
|
||||
import { extractLastOscTitle } from '../components/terminal-pane/pty-transport'
|
||||
|
||||
|
|
@ -188,6 +190,36 @@ describe('clearWorkingIndicators', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('normalizeTerminalTitle', () => {
|
||||
it('collapses Gemini working titles to a stable label', () => {
|
||||
expect(normalizeTerminalTitle('✦ Typing prompt... (workspace)')).toBe('✦ Gemini CLI')
|
||||
expect(normalizeTerminalTitle('⏲ Working… (workspace)')).toBe('✦ Gemini CLI')
|
||||
})
|
||||
|
||||
it('collapses Gemini idle and permission titles to stable labels', () => {
|
||||
expect(normalizeTerminalTitle('◇ Ready (workspace)')).toBe('◇ Gemini CLI')
|
||||
expect(normalizeTerminalTitle('✋ Action Required (workspace)')).toBe('✋ Gemini CLI')
|
||||
})
|
||||
|
||||
it('leaves non-Gemini titles unchanged', () => {
|
||||
expect(normalizeTerminalTitle('⠂ Claude Code')).toBe('⠂ Claude Code')
|
||||
expect(normalizeTerminalTitle('bash')).toBe('bash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGeminiTerminalTitle', () => {
|
||||
it('detects Gemini titles by symbol or name', () => {
|
||||
expect(isGeminiTerminalTitle('✦ Typing prompt... (workspace)')).toBe(true)
|
||||
expect(isGeminiTerminalTitle('◇ Ready (workspace)')).toBe(true)
|
||||
expect(isGeminiTerminalTitle('gemini waiting for input')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other terminal titles', () => {
|
||||
expect(isGeminiTerminalTitle('⠂ Claude Code')).toBe(false)
|
||||
expect(isGeminiTerminalTitle('bash')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAgentStatusTracker', () => {
|
||||
// --- Claude Code: real captured OSC title sequence (v2.1.86) ---
|
||||
// CRITICAL: Claude Code changes the title to the TASK DESCRIPTION,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@ const GEMINI_PERMISSION = '\u270B' // ✋
|
|||
|
||||
const AGENT_NAMES = ['claude', 'codex', 'gemini', 'opencode', 'aider']
|
||||
|
||||
export function isGeminiTerminalTitle(title: string): boolean {
|
||||
return (
|
||||
title.includes(GEMINI_PERMISSION) ||
|
||||
title.includes(GEMINI_WORKING) ||
|
||||
title.includes(GEMINI_SILENT_WORKING) ||
|
||||
title.includes(GEMINI_IDLE) ||
|
||||
title.toLowerCase().includes('gemini')
|
||||
)
|
||||
}
|
||||
|
||||
function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
|
|
@ -89,6 +99,32 @@ export function createAgentStatusTracker(onBecameIdle: () => void): {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize high-churn agent titles into stable display labels before storing
|
||||
* them in app state. Gemini CLI can emit per-keystroke title updates, which
|
||||
* otherwise causes broad rerenders and visible flashing.
|
||||
*/
|
||||
export function normalizeTerminalTitle(title: string): string {
|
||||
if (!title) {
|
||||
return title
|
||||
}
|
||||
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'permission') {
|
||||
return `${GEMINI_PERMISSION} Gemini CLI`
|
||||
}
|
||||
if (status === 'working') {
|
||||
return `${GEMINI_WORKING} Gemini CLI`
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return `${GEMINI_IDLE} Gemini CLI`
|
||||
}
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { safeFit } from './pane-tree-ops'
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TERMINAL_PADDING = 4
|
||||
const ENABLE_WEBGL_RENDERER = true
|
||||
|
||||
export function createPaneDOM(
|
||||
id: number,
|
||||
|
|
@ -102,6 +103,7 @@ export function createPaneDOM(
|
|||
container,
|
||||
xtermContainer,
|
||||
linkTooltip,
|
||||
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
unicode11Addon,
|
||||
|
|
@ -146,8 +148,9 @@ export function openTerminal(pane: ManagedPaneInternal): void {
|
|||
// Activate unicode 11
|
||||
terminal.unicode.activeVersion = '11'
|
||||
|
||||
// Attach GPU renderer
|
||||
attachWebgl(pane)
|
||||
if (pane.gpuRenderingEnabled) {
|
||||
attachWebgl(pane)
|
||||
}
|
||||
|
||||
// Initial fit (deferred to ensure layout has settled)
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -156,6 +159,10 @@ export function openTerminal(pane: ManagedPaneInternal): void {
|
|||
}
|
||||
|
||||
export function attachWebgl(pane: ManagedPaneInternal): void {
|
||||
if (!ENABLE_WEBGL_RENDERER || !pane.gpuRenderingEnabled) {
|
||||
pane.webglAddon = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const webglAddon = new WebglAddon()
|
||||
webglAddon.onContextLoss(() => {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type ManagedPane = {
|
|||
export type ManagedPaneInternal = {
|
||||
xtermContainer: HTMLElement
|
||||
linkTooltip: HTMLElement
|
||||
gpuRenderingEnabled: boolean
|
||||
webglAddon: WebglAddon | null
|
||||
unicode11Addon: Unicode11Addon
|
||||
webLinksAddon: WebLinksAddon
|
||||
|
|
|
|||
|
|
@ -209,6 +209,32 @@ export class PaneManager {
|
|||
applyRootBackground(this.root, this.styleOptions)
|
||||
}
|
||||
|
||||
setPaneGpuRendering(paneId: number, enabled: boolean): void {
|
||||
const pane = this.panes.get(paneId)
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
|
||||
pane.gpuRenderingEnabled = enabled
|
||||
|
||||
if (!enabled) {
|
||||
if (pane.webglAddon) {
|
||||
try {
|
||||
pane.webglAddon.dispose()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pane.webglAddon = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!pane.webglAddon) {
|
||||
attachWebgl(pane)
|
||||
safeFit(pane)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend GPU rendering for all panes. Disposes WebGL addons to free
|
||||
* GPU contexts while keeping Terminal instances alive (scrollback, cursor,
|
||||
|
|
@ -233,7 +259,7 @@ export class PaneManager {
|
|||
*/
|
||||
resumeRendering(): void {
|
||||
for (const pane of this.panes.values()) {
|
||||
if (!pane.webglAddon) {
|
||||
if (pane.gpuRenderingEnabled && !pane.webglAddon) {
|
||||
attachWebgl(pane)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,11 +150,18 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
set((s) => {
|
||||
let changed = false
|
||||
const next = { ...s.tabsByWorktree }
|
||||
for (const wId of Object.keys(next)) {
|
||||
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, title } : t))
|
||||
next[wId] = next[wId].map((t) => {
|
||||
if (t.id !== tabId || t.title === title) {
|
||||
return t
|
||||
}
|
||||
changed = true
|
||||
return { ...t, title }
|
||||
})
|
||||
}
|
||||
return { tabsByWorktree: next }
|
||||
return changed ? { tabsByWorktree: next } : s
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue