fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554)

An idle agent pane kept ~40% of a core busy just by being frontmost. The
xterm cursor and the native chat caret blink with no paint-containment
boundary, so Chromium treated each blink as damage to the whole pane
ancestry and re-rasterized it twice a second.

- `.xterm-container` and the native composer's input shell get
  `contain: paint`, bounding blink damage to the surface that blinks.
- The mention hint gains `z-20` to match the slash picker: a contained
  element becomes a stacking context and paints at z-index 0 in tree
  order, which would otherwise cover the hint's drop shadow.

Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins
over the option in `_updateCursorBlink` — so parking `cursorBlink` does not
reliably stop a hidden pane blinking. Pre-existing, documented only.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
This commit is contained in:
Wooseong Kim
2026-09-14 14:27:31 -07:00
committed by GitHub
co-authored by Wooseong Kim
parent 389d672dab
commit 46eb5959fa
6 changed files with 61 additions and 3 deletions
@@ -15,4 +15,8 @@ describe('terminal container geometry', () => {
/\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s
)
})
it('bounds cursor-blink repaints to the terminal surface (#10481)', () => {
expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*contain:\s*paint;/s)
})
})
+4
View File
@@ -512,6 +512,10 @@
height: calc(100% - var(--pane-padding-y, 4px));
margin-top: var(--pane-padding-y, 4px);
margin-left: var(--pane-padding-x, 4px);
/* Why (#10481): a blinking cursor otherwise invalidates paint all the way up
the pane ancestry. The link tooltip and drag handle are .pane siblings, so
clipping to this box costs no visible chrome. */
contain: paint;
}
/* When a pane has a title, shift the terminal content down to make room.
@@ -276,7 +276,10 @@ export function NativeChatMentionHint({
event.preventDefault()
onAccept()
}}
className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
// Why z-20: matches the slash picker. The composer shell below is a paint
// containment boundary (#10481), so it now paints at z-index 0 in tree
// order and would otherwise cover this hint's drop shadow.
className="absolute bottom-full left-3 right-3 z-20 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
>
{translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '}
<span className="font-medium text-foreground">@{query || '…'}</span>
@@ -192,7 +192,15 @@ export function NativeChatComposerField({
// no focus/click border flash. The box is a container, not a
// focus target.
'rounded-lg border border-border p-1.5 shadow-xs',
'bg-muted/50 dark:bg-input/40'
'bg-muted/50 dark:bg-input/40',
// Why (#10481): the native caret blink invalidates paint up to the
// nearest containment boundary; without this the whole transcript
// re-rasterizes twice a second. Pickers are siblings and every menu
// and tooltip in here is a Radix portal, so nothing floating clips.
// Tightest descendant is the attachment remove button, which
// overhangs its thumbnail by 6px and clears this box's padding by
// 4px — keep that slack if the padding below ever shrinks.
'[contain:paint]'
)}
>
{imageAttachments.length > 0 ? (
@@ -0,0 +1,33 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
const composerField = fs.readFileSync(
new URL('./NativeChatComposerField.tsx', import.meta.url),
'utf8'
)
const autocompleteMenus = fs.readFileSync(
new URL('./NativeChatAutocompleteMenus.tsx', import.meta.url),
'utf8'
)
describe('native chat composer paint containment (#10481)', () => {
it('bounds caret repaints to the composer input shell', () => {
expect(composerField).toContain('[contain:paint]')
})
it('keeps the outer composer uncontained so the pickers can overflow it', () => {
// The pickers are siblings that render above the shell via `bottom-full`;
// containing their parent would clip them.
const outerShell = composerField.slice(0, composerField.indexOf('[contain:paint]'))
expect(outerShell).toContain('<div className="shrink-0 bg-background">')
expect(outerShell).not.toContain('contain:paint')
})
it('lifts both pickers above the contained shell', () => {
// The shell is a stacking context now, so it paints at z-index 0 in tree
// order — an unlayered picker would lose its drop shadow to it.
for (const picker of ['bottom-full left-0 right-0 z-20', 'bottom-full left-3 right-3 z-20']) {
expect(autocompleteMenus).toContain(picker)
}
})
})
@@ -12,10 +12,16 @@ import type { Terminal } from '@xterm/xterm'
* and the pane blinks — redrawing its whole cursor row through
* `WebglRenderer._updateModel` — until the 5-minute idle timeout.
*
* `cursorBlink` is the public option that tears the timer down deterministically
* `cursorBlink` is the public option that tears the timer down
* (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so
* "a hidden pane does not blink" stops depending on which CSS hid it.
*
* Not unconditional, though: `_updateCursorBlink` resolves
* `decPrivateModes.cursorBlink ?? options.cursorBlink`, and DECSCUSR with a
* blinking style (`CSI 5 SP q`) pins that DEC mode. On a pane whose shell or agent
* has emitted one, parking the option here has no effect and the hidden pane keeps
* blinking. Making this deterministic means clearing the DEC mode too.
*
* Resume restores the parked value rather than the settings value, so a pane that
* was not blinking before the hide never comes back blinking.
*/