diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..cf17cb00540 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 1ee09586892..0d17b1f77df 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -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. diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 125d4326b8f..2e1d05e0ffe 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -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:')}{' '} @{query || '…'} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..a4b104efd0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -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 ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts new file mode 100644 index 00000000000..2c8a08ffa89 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts @@ -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('
') + 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) + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts index 13f90af696c..6cdb5dd7508 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -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. */