fix: reduce Gemini terminal flashing (#203)

This commit is contained in:
Jinjing 2026-03-29 15:26:09 -07:00 committed by GitHub
parent 6bfe7aaaeb
commit ba7634ed0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 125 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

@ -43,6 +43,7 @@ export type ManagedPane = {
export type ManagedPaneInternal = {
xtermContainer: HTMLElement
linkTooltip: HTMLElement
gpuRenderingEnabled: boolean
webglAddon: WebglAddon | null
unicode11Addon: Unicode11Addon
webLinksAddon: WebLinksAddon

View file

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

View file

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