Let terminal pane controls float over output

This commit is contained in:
brennanb2025
2026-06-23 22:41:52 -07:00
parent 5106e33947
commit ea991e1187
7 changed files with 41 additions and 25 deletions
+4 -4
View File
@@ -166,14 +166,14 @@
--terminal-pane-title-on-dark-fg: rgb(255 255 255 / 0.52);
--terminal-pane-title-on-dark-input-fg: rgb(255 255 255 / 0.7);
--terminal-pane-title-on-dark-placeholder: rgb(255 255 255 / 0.38);
--terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.3);
--terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.48);
--terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255 / 0.8);
--terminal-pane-title-on-dark-input-bg: rgb(255 255 255 / 0.04);
--terminal-pane-title-on-dark-separator: rgb(255 255 255 / 0.06);
--terminal-pane-title-on-light-fg: rgb(24 24 27 / 0.64);
--terminal-pane-title-on-light-input-fg: rgb(24 24 27 / 0.82);
--terminal-pane-title-on-light-placeholder: rgb(24 24 27 / 0.48);
--terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.42);
--terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.58);
--terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27 / 0.82);
--terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05);
--terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1);
@@ -247,14 +247,14 @@
--terminal-pane-title-on-dark-fg: rgb(255 255 255 / 0.52);
--terminal-pane-title-on-dark-input-fg: rgb(255 255 255 / 0.7);
--terminal-pane-title-on-dark-placeholder: rgb(255 255 255 / 0.38);
--terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.3);
--terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.48);
--terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255 / 0.8);
--terminal-pane-title-on-dark-input-bg: rgb(255 255 255 / 0.04);
--terminal-pane-title-on-dark-separator: rgb(255 255 255 / 0.06);
--terminal-pane-title-on-light-fg: rgb(24 24 27 / 0.64);
--terminal-pane-title-on-light-input-fg: rgb(24 24 27 / 0.82);
--terminal-pane-title-on-light-placeholder: rgb(24 24 27 / 0.48);
--terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.42);
--terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.58);
--terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27 / 0.82);
--terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05);
--terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1);
+5
View File
@@ -353,6 +353,11 @@
background: transparent;
border-bottom: none;
padding: 0 4px;
pointer-events: none;
}
.pane-title-bar[data-chromeless] .pane-title-actions {
pointer-events: auto;
}
.pane-title-close {
@@ -1760,27 +1760,24 @@ export default function TerminalPane({
return subscribeTerminalPaneAttention(tabId, applyTerminalPaneAttention)
}, [tabId, paneCount, applyTerminalPaneAttention])
// Sync the pane title reservation before paint. The title UI stays outside
// xterm's DOM, but xterm must still fit below it so the first terminal row is
// never hidden under the title strip.
// Sync the pane title reservation before paint. Text/banner chrome stays
// outside xterm's DOM, but xterm must still fit below it so the first
// terminal row is never hidden under meaningful status UI.
useLayoutEffect(() => {
const manager = managerRef.current
if (!manager) {
return
}
// Show the title bar space when the pane has a title, is being
// inline-edited, or has transient startup chrome. Unread activity does
// not reserve this space; its overlay should not reflow terminal content.
// Show title space only for text/status chrome. Chromeless pane controls
// float over xterm so untitled panes keep their first row.
const needsFit = syncSessionRestoredBannerTitleSpace({
panes: manager.getPanes(),
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds,
reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup)
sessionRestoredBannerPaneIds
})
if (needsFit && (isVisible || shouldMeasureHiddenStartup)) {
// Why: inactive terminal tabs can drop active-only header reservation.
// Fitting that hidden geometry changes PTY rows and wakes TUIs with
// Why: fitting hidden geometry changes PTY rows and wakes TUIs with
// SIGWINCH; the visible resume path owns any real layout correction.
fitPanes(manager)
}
@@ -1790,7 +1787,6 @@ export default function TerminalPane({
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds,
isActive,
isVisible,
shouldMeasureHiddenStartup
])
@@ -127,11 +127,13 @@ describe('TerminalPaneHeaderOverlay', () => {
expect(onClosePane).not.toHaveBeenCalledWith(1)
})
it('keeps close-pane available for untitled split pane headers', () => {
it('keeps split and close-pane controls available for untitled split pane headers', () => {
const { container, onClosePane, onRemoveTitle } = renderOverlay({
paneTitles: { 1: '', 2: '' }
})
expect(container.querySelector('button[aria-label="Split Terminal Right"]')).not.toBeNull()
expect(container.querySelector('.pane-title-drag-handle')).toBeNull()
const closePane = container.querySelector<HTMLButtonElement>('button[aria-label="Close Pane"]')
expect(closePane).not.toBeNull()
@@ -182,7 +182,7 @@ export default function TerminalPaneHeaderOverlay({
/>
) : (
<>
{paneCount > 1 && (
{paneCount > 1 && !isChromeless && (
<div
className="pane-title-drag-handle"
aria-hidden="true"
@@ -205,7 +205,7 @@ export default function TerminalPaneHeaderOverlay({
{title}
</button>
) : null}
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0.5">
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
{showAlwaysOnHeaders ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -76,7 +76,7 @@ describe('session restored banner pane state', () => {
expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
})
it('reserves title space for always-on pane headers on the active tab', () => {
it('does not reserve title space for chromeless always-on pane headers', () => {
const activePane = createPane(1)
const secondPane = createPane(2)
@@ -84,13 +84,28 @@ describe('session restored banner pane state', () => {
panes: [activePane, secondPane],
paneTitles: {},
renamingPaneId: null,
sessionRestoredBannerPaneIds: new Set(),
reservePaneHeaderSpace: true
sessionRestoredBannerPaneIds: new Set()
})
expect(needsFit).toBe(false)
expect(activePane.container.hasAttribute('data-has-title')).toBe(false)
expect(secondPane.container.hasAttribute('data-has-title')).toBe(false)
})
it('reserves title space for explicit titles and inline rename', () => {
const titledPane = createPane(1)
const renamingPane = createPane(2)
const needsFit = syncSessionRestoredBannerTitleSpace({
panes: [titledPane, renamingPane],
paneTitles: { [titledPane.id]: 'server' },
renamingPaneId: renamingPane.id,
sessionRestoredBannerPaneIds: new Set()
})
expect(needsFit).toBe(true)
expect(activePane.container.hasAttribute('data-has-title')).toBe(true)
expect(secondPane.container.hasAttribute('data-has-title')).toBe(true)
expect(titledPane.container.hasAttribute('data-has-title')).toBe(true)
expect(renamingPane.container.hasAttribute('data-has-title')).toBe(true)
})
it('renders and reserves title space only on the restored inactive split pane', async () => {
@@ -88,12 +88,10 @@ export function syncSessionRestoredBannerTitleSpace(args: {
paneTitles: Readonly<Record<number, string>>
renamingPaneId: number | null
sessionRestoredBannerPaneIds: ReadonlySet<number>
reservePaneHeaderSpace?: boolean
}): boolean {
let needsFit = false
for (const pane of args.panes) {
const shouldShow =
args.reservePaneHeaderSpace === true ||
!!args.paneTitles[pane.id] ||
args.renamingPaneId === pane.id ||
args.sessionRestoredBannerPaneIds.has(pane.id)