From a86cf511685b6fc79c873ba4de7e6e7ffa38c592 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 09:17:35 -0700 Subject: [PATCH 01/12] feat(window): run the app background under the native title bar On the native title bar path the system painted its own strip above the content, so the window read as two pieces: an OS bar, then the terminal. macOS exposes the client properties to fix that - fullWindowContent lets the content pane extend under the title bar and transparentTitleBar stops the system painting there - so the terminal background now runs edge to edge with the traffic lights sitting directly on it. The title stays visible, centred in that strip. Content is inset by the title bar height so the tab bar is not left underneath the traffic lights, where it would be unclickable. That height is a constant rather than a measurement: with fullWindowContent the content pane fills the frame, so the usual "window height minus content height" reports zero. Deliberately NOT transparency, and the KDoc records why so it is not re-litigated. Those are separate things and only this one is available with a native title bar: AWT allocates an alpha-capable backing store only for windows it treats as translucent and refuses that for decorated frames (IllegalComponentStateException: The frame is decorated). Forcing the NSWindow non-opaque underneath is not enough - measured, the window reports isOpaque = NO and alpha still composites onto black, because the surface has no alpha channel. macOS itself allows it, which is how Terminal.app is see-through with traffic lights, but not through an AWT window. Transparency therefore stays what it has always been here: the undecorated path's. Client properties are the supported JDK route on macOS and are ignored elsewhere; gated on macOS anyway so the intent is obvious. Not yet checked by hand: fullscreen, where macOS hides the title bar but the inset is currently unconditional, so there may be reserved space at the top. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 22 +++++++ .../compose/window/NativeTitleBarStyle.kt | 59 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index dfce38070..c947cf146 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -2,6 +2,8 @@ package ai.rever.bossterm.app import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +34,8 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar +import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT_DP +import ai.rever.bossterm.compose.window.applyFullWindowContent import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager @@ -284,6 +288,16 @@ fun main(args: Array) { } } ) { + // Native title bar styling: let the app's background run under the title bar so + // the traffic lights sit on it, instead of a system-painted strip above the + // content. Only meaningful on the native path - the custom title bar already + // owns that area. `this@Window.window` because the loop variable above shadows + // FrameWindowScope.window. + val fullWindowContent = + remember(useNativeTitleBar) { + useNativeTitleBar && applyFullWindowContent(this@Window.window) + } + // Update manager state val updateManager = remember { UpdateManager.instance } val updateState by updateManager.updateState.collectAsState() @@ -810,6 +824,14 @@ fun main(args: Array) { } Column(modifier = Modifier.fillMaxSize()) { + // The content pane now extends under the title bar, so reserve its + // height - otherwise the tab bar would sit beneath the traffic + // lights and be unclickable. The app's background already paints + // through this strip, which is the point. + if (fullWindowContent) { + Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT_DP.dp)) + } + // Custom title bar (only when not using native title bar) if (!useNativeTitleBar) { CustomTitleBar( diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt new file mode 100644 index 000000000..1406a9ee1 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -0,0 +1,59 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import java.awt.Window +import javax.swing.JDialog +import javax.swing.JFrame + +/** + * Height the native macOS title bar occupies once the content extends underneath it. + * + * A constant rather than a measurement: with `fullWindowContent` the content pane fills the whole + * frame, so the usual `window.height - contentPane.height` trick reports zero. 28pt is the + * standard title bar height for a regular-sized window, and it is what the traffic lights are + * laid out against. + */ +const val NATIVE_TITLE_BAR_HEIGHT_DP: Int = 28 + +/** + * Make the native title bar part of the window rather than a strip above it. + * + * `fullWindowContent` lets the content pane extend under the title bar and `transparentTitleBar` + * stops the system painting its own background there, so the app's own background runs edge to + * edge and the traffic lights sit directly on it. That is the look every modern terminal has, and + * it is the one part of "native window styling" reachable without giving something up. + * + * Deliberately NOT transparency. Those are separate things and only this one is available with a + * native title bar: AWT allocates an alpha-capable backing store only for windows it considers + * translucent, and refuses that for decorated frames (`IllegalComponentStateException: The frame + * is decorated`). Forcing the `NSWindow` non-opaque underneath is not enough - measured, the + * window reports `isOpaque = NO` and still composites alpha onto black, because the surface has + * no alpha channel. macOS itself allows it, which is how Terminal.app is see-through with traffic + * lights, but not through AWT's window. Transparency therefore remains the undecorated path's. + * + * Client properties are the supported JDK route on macOS and are simply ignored elsewhere, so + * this is gated on macOS only to keep the intent obvious. + * + * @return true when the style was applied, so callers know whether to inset their content by + * [NATIVE_TITLE_BAR_HEIGHT_DP] to keep it clear of the traffic lights. + */ +fun applyFullWindowContent(window: Window): Boolean { + if (!ShellCustomizationUtils.isMacOS()) return false + + val rootPane = + when (window) { + is JFrame -> window.rootPane + is JDialog -> window.rootPane + else -> null + } ?: return false + + return runCatching { + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + // The title stays visible. It is drawn centred in the title bar strip, and callers reserve + // exactly that strip with NATIVE_TITLE_BAR_HEIGHT_DP, so there is nothing for it to overlap + // - hiding it would just lose the window name for no reason. + rootPane.putClientProperty("apple.awt.windowTitleVisible", true) + true + }.getOrDefault(false) +} From 3050110117610fe0d01fa2b052046700455974d3 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 09:17:35 -0700 Subject: [PATCH 02/12] fix(window): show the active tab's title in the window title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window title was fed from display.windowTitleFlow, the OSC 2 window title. Most shells never emit it, so the window sat on its initial "BossTerm" forever while the tab bar beside it tracked the working directory from an entirely different source - two titles for one pane, one of them dead. It now reads session.title, the value every other surface already uses: a Rename… custom title when set, otherwise the cwd label, with an app's OSC 0/1 icon title mirrored in and re-asserted on each fresh prompt so a full-screen app's name reverts on exit. Focus tracking is unchanged, so it still follows the focused split pane rather than the first one. Consequence worth naming: a shell that does emit OSC 2 with something other than the tab title will now show the tab title instead. That is the point - the two surfaces agreeing is what was missing. --- .../ai/rever/bossterm/compose/TabbedTerminal.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index e3d4228cf..80fedeba2 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1626,12 +1626,20 @@ fun TabbedTerminal( val activeTab = tabController.tabs[tabController.activeTabIndex] val splitState = getOrCreateSplitState(activeTab) - // Update the OS window title from the FOCUSED pane's window title. + // Update the OS window title from the FOCUSED pane's tab title. // Re-subscribe when focus moves between split panes so the window title // follows the active pane instead of always the root/first pane. + // + // Deliberately session.title, not display.windowTitleFlow. The latter is the OSC 2 + // window title, which most shells never emit - so the window was left showing its + // initial "BossTerm" while the tab bar beside it tracked the directory. session.title + // is the resolved one every other surface already uses: a Rename… custom title if set, + // otherwise the cwd label, with an app's OSC 0/1 icon title mirrored in and re-asserted + // on each fresh prompt. Sharing that source is what keeps the title bar and the tab + // agreeing. LaunchedEffect(activeTab, splitState.focusedPaneId) { val focused = splitState.getFocusedSession() ?: activeTab - focused.display.windowTitleFlow.collect { newTitle -> + snapshotFlow { focused.title.value }.collect { newTitle -> if (newTitle.isNotEmpty()) { onWindowTitleChange(newTitle) } From 65364eebc1756e1bb5e0ec246734eb2303bcb90c Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 09:50:29 -0700 Subject: [PATCH 03/12] Address review on the full window content title bar Fullscreen inset was the one real bug: the 28dp strip was reserved unconditionally, so macOS fullscreen (where the title bar is gone) left a dead band of background above the tabs. Gated on Fullscreen only, not the existing isFullscreenOrMaximized, because macOS zoom keeps the title bar and gating on that would put the tab bar back under the traffic lights whenever the window is zoomed. Also from the review: - split applyFullWindowContent so the decision and the property writes take a JRootPane, and cover them with tests. No java.awt.Window can be constructed in a headless JVM, so the Window overload is unreachable from CI; a bare JRootPane is not, which makes most of the contract executable. - resolve the root pane via RootPaneContainer rather than a JFrame/JDialog when, which is what the JDK itself uses and covers JWindow too. - log on the runCatching failure branch instead of swallowing it. - NATIVE_TITLE_BAR_HEIGHT is a Dp rather than a bare Int of implied units. - explain in the useNativeTitleBar KDoc and the settings copy WHY there is no transparency, pointing at the measurement, so it is not re-litigated. - document why the apply runs in remember rather than SideEffect: it has to land before the content measures or the window jumps a frame. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 16 +++-- .../compose/settings/TerminalSettings.kt | 7 ++ .../sections/VisualSettingsSection.kt | 3 +- .../compose/window/NativeTitleBarStyle.kt | 42 ++++++++---- .../compose/window/NativeTitleBarStyleTest.kt | 68 +++++++++++++++++++ 5 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index c947cf146..9a0c5873e 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -34,7 +34,7 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar -import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT_DP +import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT import ai.rever.bossterm.compose.window.applyFullWindowContent import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig @@ -293,8 +293,13 @@ fun main(args: Array) { // content. Only meaningful on the native path - the custom title bar already // owns that area. `this@Window.window` because the loop variable above shadows // FrameWindowScope.window. + // remember rather than SideEffect on purpose: this has to run before the + // content below measures, so the inset is right on the very first frame + // instead of the window jumping once. The write is an idempotent client + // property on a stable window, so a discarded composition costs nothing. + // Keyed on Unit because useNativeTitleBar is itself captured once at startup. val fullWindowContent = - remember(useNativeTitleBar) { + remember { useNativeTitleBar && applyFullWindowContent(this@Window.window) } @@ -828,8 +833,11 @@ fun main(args: Array) { // height - otherwise the tab bar would sit beneath the traffic // lights and be unclickable. The app's background already paints // through this strip, which is the point. - if (fullWindowContent) { - Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT_DP.dp)) + // Fullscreen ONLY, never Maximized: macOS zoom keeps the title bar, + // so gating on "fullscreen or maximized" would put the tab bar back + // under the traffic lights whenever the window is zoomed. + if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) { + Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT)) } // Custom title bar (only when not using native title bar) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt index ba85ecfd4..ecdc6a521 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt @@ -169,6 +169,13 @@ data class TerminalSettings( * When true: Native macOS title bar, proper fullscreen, but no transparency. * When false: Custom title bar, transparency works, but no true fullscreen. * Changing this requires app restart to take effect. + * + * "No transparency" is an AWT restriction, not a macOS one - see + * [ai.rever.bossterm.compose.window.applyFullWindowContent], which records what was measured. + * Short version: AWT gives an alpha-capable backing store only to windows it treats as + * translucent and refuses that for decorated frames, and forcing the NSWindow non-opaque + * underneath does not help because the surface has no alpha channel. Do not spend an + * afternoon rediscovering it. */ val useNativeTitleBar: Boolean = true, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt index 40a992be1..3eaa8d20b 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/sections/VisualSettingsSection.kt @@ -145,7 +145,8 @@ fun VisualSettingsSection( } }, description = if (settings.useNativeTitleBar) { - "Native macOS title bar with proper fullscreen (no transparency)" + "Native macOS title bar and traffic lights over the terminal background, " + + "with proper fullscreen. Transparency is not available with it." } else { "Custom title bar with transparency support" } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index 1406a9ee1..b6027b29e 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -1,9 +1,11 @@ package ai.rever.bossterm.compose.window import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import java.awt.Window -import javax.swing.JDialog -import javax.swing.JFrame +import javax.swing.JRootPane +import javax.swing.RootPaneContainer /** * Height the native macOS title bar occupies once the content extends underneath it. @@ -13,7 +15,7 @@ import javax.swing.JFrame * standard title bar height for a regular-sized window, and it is what the traffic lights are * laid out against. */ -const val NATIVE_TITLE_BAR_HEIGHT_DP: Int = 28 +val NATIVE_TITLE_BAR_HEIGHT: Dp = 28.dp /** * Make the native title bar part of the window rather than a strip above it. @@ -35,17 +37,25 @@ const val NATIVE_TITLE_BAR_HEIGHT_DP: Int = 28 * this is gated on macOS only to keep the intent obvious. * * @return true when the style was applied, so callers know whether to inset their content by - * [NATIVE_TITLE_BAR_HEIGHT_DP] to keep it clear of the traffic lights. + * [NATIVE_TITLE_BAR_HEIGHT] to keep it clear of the traffic lights. False off macOS, or + * for a window with no root pane, in which case the caller must not inset - leaving + * today's behaviour rather than a stray gap. */ -fun applyFullWindowContent(window: Window): Boolean { - if (!ShellCustomizationUtils.isMacOS()) return false +fun applyFullWindowContent(window: Window): Boolean = + // RootPaneContainer is the interface the JDK itself uses for this, and it covers JWindow too. + applyFullWindowContent((window as? RootPaneContainer)?.rootPane) - val rootPane = - when (window) { - is JFrame -> window.rootPane - is JDialog -> window.rootPane - else -> null - } ?: return false +/** + * The decision and the three property writes, split out from finding the root pane so a headless + * test can reach them: no [java.awt.Window] can be constructed headless (its constructor throws + * `HeadlessException`) but a bare [JRootPane] can, so this is the largest part of the contract CI + * can actually execute. + * + * @param rootPane null when the window has no root pane to style, which is a decline, not a crash. + */ +internal fun applyFullWindowContent(rootPane: JRootPane?): Boolean { + if (!ShellCustomizationUtils.isMacOS()) return false + if (rootPane == null) return false return runCatching { rootPane.putClientProperty("apple.awt.fullWindowContent", true) @@ -55,5 +65,11 @@ fun applyFullWindowContent(window: Window): Boolean { // - hiding it would just lose the window name for no reason. rootPane.putClientProperty("apple.awt.windowTitleVisible", true) true - }.getOrDefault(false) + }.getOrElse { + // putClientProperty on a JRootPane essentially cannot throw, so reaching here means + // something is badly wrong - exactly when a silent un-inset window would be the worst + // outcome to debug. + println("NativeTitleBarStyle: could not apply full window content: $it") + false + } } diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt new file mode 100644 index 000000000..42b4091cb --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -0,0 +1,68 @@ +package ai.rever.bossterm.compose.window + +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils +import androidx.compose.ui.unit.dp +import javax.swing.JRootPane +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The part of the native title bar style a headless run can see. + * + * Whether the traffic lights end up painted on the app's own background is an AppKit rendering + * property and is not assertable here - that stays manual. What IS assertable is the contract the + * caller depends on: the return value decides whether the window reserves + * [NATIVE_TITLE_BAR_HEIGHT] at the top, so a wrong answer is either a 28dp dead band or a tab bar + * under the traffic lights. + * + * These go through the [JRootPane] overload because no [java.awt.Window] can be constructed in a + * headless JVM at all - the constructor throws `HeadlessException`, and CI is headless on every + * runner including macOS. + */ +class NativeTitleBarStyleTest { + @Test + fun `a window with no root pane is declined rather than half-styled`() { + // Returning false is what stops the caller reserving 28dp for a title bar that will never + // be styled, which would show up as a strip of background above the tabs. + assertFalse(applyFullWindowContent(null as JRootPane?)) + } + + @Test + fun `on macOS the three client properties that produce the style are set`() { + if (!ShellCustomizationUtils.isMacOS()) return + + val rootPane = JRootPane() + assertTrue(applyFullWindowContent(rootPane), "macOS should accept a real root pane") + + // fullWindowContent extends the content pane under the title bar, transparentTitleBar stops + // the system painting its own background over it. Either one alone gives a broken look: + // the first without the second leaves a grey strip, the second without the first leaves the + // content below it. + assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent")) + assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar")) + // The window title stays visible; the caller reserves exactly the strip it is drawn in. + assertEquals(true, rootPane.getClientProperty("apple.awt.windowTitleVisible")) + } + + @Test + fun `off macOS nothing is applied and the caller does not inset`() { + if (ShellCustomizationUtils.isMacOS()) return + + // These client properties are read by the macOS JDK only. Elsewhere the platform draws its + // own decorations, so this must be a no-op and the window must not reserve the strip. + val rootPane = JRootPane() + assertFalse(applyFullWindowContent(rootPane)) + assertNull(rootPane.getClientProperty("apple.awt.fullWindowContent")) + } + + @Test + fun `the reserved strip is the standard macOS title bar height`() { + // A constant rather than a measurement: with fullWindowContent the content pane fills the + // frame, so the usual "window height minus content height" reports zero. 28pt is what the + // traffic lights are laid out against for a regular-size window. + assertEquals(28.dp, NATIVE_TITLE_BAR_HEIGHT) + } +} From 61bebeaa85fdc9ae2989ef8433801e303b5154d1 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 10:01:15 -0700 Subject: [PATCH 04/12] Keep the hotkey hint out of the title bar, restore OSC 2 Second review pass. The global-hotkey hint is a sibling of the Column that carries the title bar spacer, anchored to the frame top, so with full window content it rendered inside the title bar strip and crowded the centred window title. It now adds the strip height to its own top padding. OSC 2 had become inert app-wide: moving the window title onto session.title left windowTitleFlow with no consumer outside EmbeddableTerminal, so the app and an embedding host derived the title from different sources and a TUI that sets only OSC 2 (vim's t_ts, a bare printf) had no effect anywhere. Merged it into the existing OSC 0/1 collector in wireCwdTitle, which already has the semantics it needs: customTitle wins, and the prompt-reset listener reverts on exit. Tests: the null check now runs before the platform check, since a window with no root pane is a decline everywhere and checking the platform first made that branch unreachable off macOS. The platform is a parameter with a live default, so all four assertions run on every runner instead of each one skipping the half it cannot see. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 9 ++++++++- .../bossterm/compose/tabs/TabController.kt | 18 ++++++++++++------ .../compose/window/NativeTitleBarStyle.kt | 13 ++++++++++--- .../compose/window/NativeTitleBarStyleTest.kt | 17 +++++++---------- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 9a0c5873e..da70b0e80 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -935,10 +935,17 @@ fun main(args: Array) { // Hotkey hint overlay (top-right corner, like iTerm2) // Shows for native title bar; custom title bar shows it in the title bar itself if (useNativeTitleBar && globalHotkeyHint != null) { + // This Box is a sibling of the Column above, so it is NOT + // covered by that Column's title bar spacer - it anchors to the + // frame top. Without the extra inset it would render inside the + // title bar strip and crowd the centred window title. Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 12.dp) + .padding( + top = if (fullWindowContent) NATIVE_TITLE_BAR_HEIGHT + 8.dp else 8.dp, + end = 12.dp + ) ) { Text( text = globalHotkeyHint, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 8a4183320..32bbabf3a 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.merge import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay @@ -155,13 +156,18 @@ class TabController( session.terminal.addCommandStateListener(titleResetListener) session.commandStateListeners.add(titleResetListener) - // Mirror an app's OSC 0/1 icon title (e.g. "claude") onto the tab name so the - // LEFT TAB BAR reflects title changes even for BACKGROUND (unfocused) tabs. - // ProperTerminal also collects this, but only for the active tab's mounted - // Composable; this runs for the session's whole life regardless of focus. - // customTitle (Rename…) always wins and is re-asserted by the snapshotFlow above. + // Mirror an app's OSC 0/1 icon title AND its OSC 2 window title (e.g. "claude") + // onto the tab name so the LEFT TAB BAR reflects title changes even for + // BACKGROUND (unfocused) tabs. ProperTerminal also collects this, but only for + // the active tab's mounted Composable; this runs for the session's whole life + // regardless of focus. + // Both, not just the icon title: shells emit OSC 0 (which sets both) but plenty + // of TUIs set only OSC 2 - vim's t_ts, and anything doing `printf '\033]2;…'`. + // Since the window title is derived from session.title, an OSC-2-only app would + // otherwise have no effect anywhere. customTitle (Rename…) always wins, and the + // prompt-reset listener above reverts either one when the app exits. session.coroutineScope.launch { - session.display.iconTitleFlow.collect { newTitle -> + merge(session.display.iconTitleFlow, session.display.windowTitleFlow).collect { newTitle -> if (newTitle.isNotEmpty() && session.customTitle.value == null) { session.title.value = newTitle } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index b6027b29e..0d0b80489 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -52,16 +52,23 @@ fun applyFullWindowContent(window: Window): Boolean = * can actually execute. * * @param rootPane null when the window has no root pane to style, which is a decline, not a crash. + * @param isMacOS injectable so a test on any runner can assert both branches, rather than each + * platform's CI silently skipping half the contract. */ -internal fun applyFullWindowContent(rootPane: JRootPane?): Boolean { - if (!ShellCustomizationUtils.isMacOS()) return false +internal fun applyFullWindowContent( + rootPane: JRootPane?, + isMacOS: Boolean = ShellCustomizationUtils.isMacOS(), +): Boolean { + // Null first: a window with no root pane is a decline on every platform, so checking the + // platform ahead of it would make the null case unreachable off macOS. if (rootPane == null) return false + if (!isMacOS) return false return runCatching { rootPane.putClientProperty("apple.awt.fullWindowContent", true) rootPane.putClientProperty("apple.awt.transparentTitleBar", true) // The title stays visible. It is drawn centred in the title bar strip, and callers reserve - // exactly that strip with NATIVE_TITLE_BAR_HEIGHT_DP, so there is nothing for it to overlap + // exactly that strip with NATIVE_TITLE_BAR_HEIGHT, so there is nothing for it to overlap // - hiding it would just lose the window name for no reason. rootPane.putClientProperty("apple.awt.windowTitleVisible", true) true diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt index 42b4091cb..3f23bc33f 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -1,6 +1,5 @@ package ai.rever.bossterm.compose.window -import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import androidx.compose.ui.unit.dp import javax.swing.JRootPane import kotlin.test.Test @@ -20,22 +19,22 @@ import kotlin.test.assertTrue * * These go through the [JRootPane] overload because no [java.awt.Window] can be constructed in a * headless JVM at all - the constructor throws `HeadlessException`, and CI is headless on every - * runner including macOS. + * runner including macOS. The platform is passed in rather than detected so that every assertion + * runs on every runner; otherwise each machine would skip the half of the contract it cannot see. */ class NativeTitleBarStyleTest { @Test - fun `a window with no root pane is declined rather than half-styled`() { + fun `a window with no root pane is declined on every platform`() { // Returning false is what stops the caller reserving 28dp for a title bar that will never // be styled, which would show up as a strip of background above the tabs. - assertFalse(applyFullWindowContent(null as JRootPane?)) + assertFalse(applyFullWindowContent(rootPane = null, isMacOS = true)) + assertFalse(applyFullWindowContent(rootPane = null, isMacOS = false)) } @Test fun `on macOS the three client properties that produce the style are set`() { - if (!ShellCustomizationUtils.isMacOS()) return - val rootPane = JRootPane() - assertTrue(applyFullWindowContent(rootPane), "macOS should accept a real root pane") + assertTrue(applyFullWindowContent(rootPane, isMacOS = true), "macOS accepts a real root pane") // fullWindowContent extends the content pane under the title bar, transparentTitleBar stops // the system painting its own background over it. Either one alone gives a broken look: @@ -49,12 +48,10 @@ class NativeTitleBarStyleTest { @Test fun `off macOS nothing is applied and the caller does not inset`() { - if (ShellCustomizationUtils.isMacOS()) return - // These client properties are read by the macOS JDK only. Elsewhere the platform draws its // own decorations, so this must be a no-op and the window must not reserve the strip. val rootPane = JRootPane() - assertFalse(applyFullWindowContent(rootPane)) + assertFalse(applyFullWindowContent(rootPane, isMacOS = false)) assertNull(rootPane.getClientProperty("apple.awt.fullWindowContent")) } From 54324354a752dc40da7ea184e2f47226054e7809 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 10:14:36 -0700 Subject: [PATCH 05/12] One inset value, measured fullscreen, and keep OSC 1 and 2 apart Third review pass. Two of these were my own regressions. The title bar predicate existed in two copies and they had already drifted: the spacer handled fullscreen, the hotkey hint did not, so in fullscreen the hint kept a 36dp offset and landed inside the tab bar row. Hoisted to a single titleBarInset that both sites use. Fullscreen is now measured from the window's bounds instead of trusting WindowState.placement. Compose Desktop syncs placement from AWT's extendedState, and a green-button fullscreen on macOS is not an extendedState transition, so placement can stay Floating right through it - which would have left the dead band the gate was written to prevent. Bounds equal to the whole display is the signal; zoom only fills the visible frame below the menu bar, which is exactly the case that must keep its inset. Both signals are OR-ed, so placement working is a bonus rather than a dependency. Merging OSC 2 into session.title was wrong: session.title is also the tab label, and xterm's split (which this codebase documents in ProperTerminal) is that OSC 1 names the tab and OSC 2 names the window. oh-my-zsh emits a short OSC 1 and a long OSC 2 back to back in precmd, so a merge made the tab label depend on which flow was scheduled last. OSC 1 goes back to being the only tab-label source, and the window title resolves its own precedence at its consumer: custom title, then OSC 2, then the tab title. The prompt-reset listener clears the OSC 2 title alongside the tab title so it still reverts when an app exits; EmbeddableTerminal already ignores empty titles, so its behaviour is unchanged. 8 tests on the two pure functions, all running on every runner. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 50 +++++++++++++------ .../rever/bossterm/compose/TabbedTerminal.kt | 32 ++++++++---- .../bossterm/compose/tabs/TabController.kt | 28 ++++++----- .../compose/window/NativeTitleBarStyle.kt | 24 +++++++++ .../compose/window/NativeTitleBarStyleTest.kt | 36 +++++++++++++ 5 files changed, 133 insertions(+), 37 deletions(-) diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index da70b0e80..9301c3f43 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -36,6 +36,7 @@ import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT import ai.rever.bossterm.compose.window.applyFullWindowContent +import ai.rever.bossterm.compose.window.isNativeFullscreen import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager @@ -748,6 +749,32 @@ fun main(args: Array) { windowState.placement == WindowPlacement.Maximized val cornerRadius = if (useNativeTitleBar || isFullscreenOrMaximized) 0.dp else 20.dp + // How much of the top the native title bar is covering right now, as ONE value + // for every site that has to stay clear of it. Two copies of this predicate + // drifted apart once already: the spacer handled fullscreen and the hotkey hint + // did not, which put the hint inside the tab bar row. + // Fullscreen is measured from the window bounds AND read from placement, since + // placement is not known to track a green-button fullscreen; either signal + // saying "fullscreen" means the title bar is gone and nothing should be + // reserved. Maximized deliberately does NOT count: macOS zoom keeps the title + // bar, so it keeps its inset. + var boundsSayFullscreen by remember { mutableStateOf(false) } + DisposableEffect(Unit) { + val frame = this@Window.window + val refresh = { boundsSayFullscreen = isNativeFullscreen(frame) } + val listener = object : java.awt.event.ComponentAdapter() { + override fun componentResized(e: java.awt.event.ComponentEvent) = refresh() + override fun componentMoved(e: java.awt.event.ComponentEvent) = refresh() + } + frame.addComponentListener(listener) + refresh() + onDispose { frame.removeComponentListener(listener) } + } + val isFullscreen = + boundsSayFullscreen || windowState.placement == WindowPlacement.Fullscreen + val titleBarInset = + if (fullWindowContent && !isFullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp + // Load background image if set val backgroundImage = remember(windowSettings.backgroundImagePath) { if (windowSettings.backgroundImagePath.isNotEmpty()) { @@ -832,13 +859,9 @@ fun main(args: Array) { // The content pane now extends under the title bar, so reserve its // height - otherwise the tab bar would sit beneath the traffic // lights and be unclickable. The app's background already paints - // through this strip, which is the point. - // Fullscreen ONLY, never Maximized: macOS zoom keeps the title bar, - // so gating on "fullscreen or maximized" would put the tab bar back - // under the traffic lights whenever the window is zoomed. - if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) { - Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT)) - } + // through this strip, which is the point. Zero in fullscreen; see + // titleBarInset. + Spacer(modifier = Modifier.height(titleBarInset)) // Custom title bar (only when not using native title bar) if (!useNativeTitleBar) { @@ -935,17 +958,14 @@ fun main(args: Array) { // Hotkey hint overlay (top-right corner, like iTerm2) // Shows for native title bar; custom title bar shows it in the title bar itself if (useNativeTitleBar && globalHotkeyHint != null) { - // This Box is a sibling of the Column above, so it is NOT - // covered by that Column's title bar spacer - it anchors to the - // frame top. Without the extra inset it would render inside the - // title bar strip and crowd the centred window title. + // This Box is a sibling of the Column above, so it is NOT covered + // by that Column's title bar spacer - it anchors to the frame top + // and has to apply the same inset itself, or it renders inside the + // title bar strip and crowds the centred window title. Box( modifier = Modifier .align(Alignment.TopEnd) - .padding( - top = if (fullWindowContent) NATIVE_TITLE_BAR_HEIGHT + 8.dp else 8.dp, - end = 12.dp - ) + .padding(top = titleBarInset + 8.dp, end = 12.dp) ) { Text( text = globalHotkeyHint, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 80fedeba2..99cc45d2d 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1630,20 +1630,32 @@ fun TabbedTerminal( // Re-subscribe when focus moves between split panes so the window title // follows the active pane instead of always the root/first pane. // - // Deliberately session.title, not display.windowTitleFlow. The latter is the OSC 2 - // window title, which most shells never emit - so the window was left showing its - // initial "BossTerm" while the tab bar beside it tracked the directory. session.title - // is the resolved one every other surface already uses: a Rename… custom title if set, - // otherwise the cwd label, with an app's OSC 0/1 icon title mirrored in and re-asserted + // Precedence, highest first: a Rename… custom title, then the app's OSC 2 window + // title, then the tab's own title. + // + // OSC 2 alone is not enough - most shells never emit it, which is what left the + // window showing its initial "BossTerm" while the tab bar beside it tracked the + // directory. session.title is the resolved fallback every other surface already + // uses: the cwd label, with an app's OSC 0/1 icon title mirrored in and re-asserted // on each fresh prompt. Sharing that source is what keeps the title bar and the tab - // agreeing. + // agreeing whenever nothing more specific has been said. + // + // But OSC 2 still wins when an app does set it, because that is what it is FOR + // (xterm's split: OSC 1 names the tab, OSC 2 names the window) and some apps + // deliberately give the window a longer string than the tab. The prompt-reset + // listener in TabController clears it on each fresh prompt, so it reverts on exit + // exactly like the tab title does. LaunchedEffect(activeTab, splitState.focusedPaneId) { val focused = splitState.getFocusedSession() ?: activeTab - snapshotFlow { focused.title.value }.collect { newTitle -> - if (newTitle.isNotEmpty()) { - onWindowTitleChange(newTitle) + snapshotFlow { focused.customTitle.value to focused.title.value } + .combine(focused.display.windowTitleFlow) { (custom, tabTitle), windowTitle -> + custom ?: windowTitle.ifEmpty { tabTitle } + } + .collect { newTitle -> + if (newTitle.isNotEmpty()) { + onWindowTitleChange(newTitle) + } } - } } // Daemon-mirrored pane: route split/close through the daemon instead of touching the diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 32bbabf3a..37f1073ef 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.merge import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay @@ -151,23 +150,28 @@ class TabController( val titleResetListener = object : ai.rever.bossterm.terminal.model.CommandStateListener { override fun onPromptStarted() { session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) + // Clear the OSC 2 window title too, so an app that set one does not keep + // naming the window after it has exited. The window title falls back to the + // tab's own title whenever this is empty - see TabbedTerminal. + session.terminal.setWindowTitle("") } } session.terminal.addCommandStateListener(titleResetListener) session.commandStateListeners.add(titleResetListener) - // Mirror an app's OSC 0/1 icon title AND its OSC 2 window title (e.g. "claude") - // onto the tab name so the LEFT TAB BAR reflects title changes even for - // BACKGROUND (unfocused) tabs. ProperTerminal also collects this, but only for - // the active tab's mounted Composable; this runs for the session's whole life - // regardless of focus. - // Both, not just the icon title: shells emit OSC 0 (which sets both) but plenty - // of TUIs set only OSC 2 - vim's t_ts, and anything doing `printf '\033]2;…'`. - // Since the window title is derived from session.title, an OSC-2-only app would - // otherwise have no effect anywhere. customTitle (Rename…) always wins, and the - // prompt-reset listener above reverts either one when the app exits. + // Mirror an app's OSC 0/1 icon title (e.g. "claude") onto the tab name so the + // LEFT TAB BAR reflects title changes even for BACKGROUND (unfocused) tabs. + // ProperTerminal also collects this, but only for the active tab's mounted + // Composable; this runs for the session's whole life regardless of focus. + // customTitle (Rename…) always wins and is re-asserted by the snapshotFlow above. + // Deliberately NOT the OSC 2 window title as well: xterm's split, which this + // codebase follows, is that OSC 1 names the TAB and OSC 2 names the WINDOW, and + // apps set them to different strings. oh-my-zsh is the case that bites - precmd + // emits a short OSC 1 ("~/src") and a long OSC 2 ("me@host: ~/src") back to back, + // so folding both in here would make the tab label whichever arrived last. The + // window title picks up OSC 2 at its own consumer in TabbedTerminal instead. session.coroutineScope.launch { - merge(session.display.iconTitleFlow, session.display.windowTitleFlow).collect { newTitle -> + session.display.iconTitleFlow.collect { newTitle -> if (newTitle.isNotEmpty() && session.customTitle.value == null) { session.title.value = newTitle } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index 0d0b80489..64571bb67 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -3,6 +3,7 @@ package ai.rever.bossterm.compose.window import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import java.awt.Rectangle import java.awt.Window import javax.swing.JRootPane import javax.swing.RootPaneContainer @@ -80,3 +81,26 @@ internal fun applyFullWindowContent( false } } + +/** + * Whether the window is in macOS native fullscreen, where the system hides the title bar entirely + * and the caller must NOT reserve [NATIVE_TITLE_BAR_HEIGHT]. + * + * Measured from the window's own bounds rather than read from `WindowState.placement`. Compose + * Desktop syncs `placement` back from AWT's `extendedState`, and macOS native fullscreen (the green + * traffic light) is not an `extendedState` transition, so `placement` can stay `Floating` right + * through it - leaving the dead band at the top that the inset gate exists to prevent. + * + * The discriminator is the menu bar. Fullscreen covers the WHOLE display; zoom (Maximized) only + * fills the visible frame, leaving the menu bar and the Dock. So an exact match against the screen + * bounds separates the two, which is precisely the distinction that matters here: zoom keeps its + * title bar and must keep its inset. + */ +internal fun isNativeFullscreen(windowBounds: Rectangle?, screenBounds: Rectangle?): Boolean { + if (windowBounds == null || screenBounds == null) return false + return windowBounds == screenBounds +} + +/** @see isNativeFullscreen */ +fun isNativeFullscreen(window: Window): Boolean = + isNativeFullscreen(window.bounds, window.graphicsConfiguration?.bounds) diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt index 3f23bc33f..5d8d4a09c 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -1,6 +1,7 @@ package ai.rever.bossterm.compose.window import androidx.compose.ui.unit.dp +import java.awt.Rectangle import javax.swing.JRootPane import kotlin.test.Test import kotlin.test.assertEquals @@ -62,4 +63,39 @@ class NativeTitleBarStyleTest { // traffic lights are laid out against for a regular-size window. assertEquals(28.dp, NATIVE_TITLE_BAR_HEIGHT) } + + // ---- isNativeFullscreen: bounds are the signal, because placement may not be ---- + + @Test + fun `covering the whole display reads as fullscreen`() { + // macOS native fullscreen takes the entire screen, menu bar strip included, so the + // window's bounds match the display's exactly. + val screen = Rectangle(0, 0, 1920, 1080) + assertTrue(isNativeFullscreen(Rectangle(0, 0, 1920, 1080), screen)) + } + + @Test + fun `a zoomed window is not fullscreen and keeps its inset`() { + // This is the case the whole function exists to separate. macOS zoom (the green button + // WITHOUT fullscreen, or Compose's Maximized) fills the VISIBLE frame - the menu bar is + // still there, so the window starts below it and is shorter than the display. The title + // bar is still on screen, so the caller must still reserve the strip. + val screen = Rectangle(0, 0, 1920, 1080) + val zoomedBelowTheMenuBar = Rectangle(0, 25, 1920, 1055) + assertFalse(isNativeFullscreen(zoomedBelowTheMenuBar, screen)) + } + + @Test + fun `an ordinary floating window is not fullscreen`() { + assertFalse(isNativeFullscreen(Rectangle(120, 80, 1200, 800), Rectangle(0, 0, 1920, 1080))) + } + + @Test + fun `unknown bounds are not fullscreen`() { + // A window with no graphics configuration yet must not be guessed as fullscreen: that + // would drop the inset and put the tab bar under the traffic lights. + val screen = Rectangle(0, 0, 1920, 1080) + assertFalse(isNativeFullscreen(null, screen)) + assertFalse(isNativeFullscreen(Rectangle(0, 0, 1920, 1080), null)) + } } From 4714bdd6019b4f05eefbfdf07baddebd57f04cc1 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 10:32:30 -0700 Subject: [PATCH 06/12] Trace placement instead of guessing it, and fix the OSC 2 clear timing Fourth review pass. The bounds-vs-screen fullscreen heuristic is gone. It was added on the theory that WindowState.placement might not see a green-button fullscreen, and the review pointed out it cannot tell fullscreen from a zoomed window once the menu bar and Dock auto-hide - which would drop the inset and slide the tab bar under the traffic lights, the exact failure the gate exists to prevent. So placement was traced rather than assumed. Compose syncs it in a componentResized handler, with a comment saying fullscreen changes fire only componentResized and not windowStateChanged; the value it reads reaches skiko's osxIsFullscreenNative, i.e. the real NSWindow state rather than a flag set only when we request fullscreen. It does track the green button, so the heuristic was not just unnecessary but strictly worse - its only effect was false positives. The trace is recorded next to the gate. Clearing the OSC 2 title moved from prompt start to command start. Shells emit their own OSC 2 from precmd, the same hook that emits 133;A, and nothing here controls the order: oh-my-zsh registers its precmd hook before a user's BossTerm snippet, so the clear landed after the title it had just set and wiped it on every prompt. At 133;B the previous program's title is genuinely stale instead. Also: the window title precedence is lifted into resolveWindowTitle and covered by tests, which is the part that changed for every user and had none; the empty string is documented as a reset on windowTitleFlow, since the other consumers only filter it; and the tests that asserted a constant and Rectangle.equals are gone with the heuristic they belonged to. Not fixed, recorded in the KDoc: with a transparent title bar AppKit picks the title colour from the window appearance, so a light system appearance draws it dark over the dark terminal background. No supported per-window appearance property exists; both workarounds give something up. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 38 ++++++-------- .../compose/ComposeTerminalDisplay.kt | 8 +++ .../rever/bossterm/compose/TabbedTerminal.kt | 17 +++++- .../bossterm/compose/tabs/TabController.kt | 17 ++++-- .../compose/window/NativeTitleBarStyle.kt | 33 ++++-------- .../compose/WindowTitleResolutionTest.kt | 52 +++++++++++++++++++ .../compose/window/NativeTitleBarStyleTest.kt | 45 ---------------- 7 files changed, 116 insertions(+), 94 deletions(-) create mode 100644 compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 9301c3f43..434ebd5e1 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -36,7 +36,6 @@ import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT import ai.rever.bossterm.compose.window.applyFullWindowContent -import ai.rever.bossterm.compose.window.isNativeFullscreen import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager @@ -753,27 +752,24 @@ fun main(args: Array) { // for every site that has to stay clear of it. Two copies of this predicate // drifted apart once already: the spacer handled fullscreen and the hotkey hint // did not, which put the hint inside the tab bar row. - // Fullscreen is measured from the window bounds AND read from placement, since - // placement is not known to track a green-button fullscreen; either signal - // saying "fullscreen" means the title bar is gone and nothing should be - // reserved. Maximized deliberately does NOT count: macOS zoom keeps the title - // bar, so it keeps its inset. - var boundsSayFullscreen by remember { mutableStateOf(false) } - DisposableEffect(Unit) { - val frame = this@Window.window - val refresh = { boundsSayFullscreen = isNativeFullscreen(frame) } - val listener = object : java.awt.event.ComponentAdapter() { - override fun componentResized(e: java.awt.event.ComponentEvent) = refresh() - override fun componentMoved(e: java.awt.event.ComponentEvent) = refresh() - } - frame.addComponentListener(listener) - refresh() - onDispose { frame.removeComponentListener(listener) } - } - val isFullscreen = - boundsSayFullscreen || windowState.placement == WindowPlacement.Fullscreen + // + // Fullscreen ONLY, never Maximized: macOS zoom keeps its title bar, so a zoomed + // window still needs the inset. placement is trustworthy here, traced rather + // than assumed - Compose syncs it in a componentResized handler specifically + // because "fullscreen changing doesn't fire windowStateChanged, only + // componentResized" (SwingWindow.desktop.kt), and the value it reads bottoms out + // in skiko's osxIsFullscreenNative, i.e. the real NSWindow state rather than a + // flag set only when WE request fullscreen. So the green button is covered. + // A bounds-vs-screen heuristic was tried instead and removed: it cannot tell + // fullscreen from a zoomed window once the menu bar and Dock auto-hide, and + // guessing "fullscreen" there would drop the inset and slide the tab bar under + // the traffic lights. val titleBarInset = - if (fullWindowContent && !isFullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp + if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) { + NATIVE_TITLE_BAR_HEIGHT + } else { + 0.dp + } // Load background image if set val backgroundImage = remember(windowSettings.backgroundImagePath) { diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt index 41f97fc0b..08dbca1d4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt @@ -129,6 +129,14 @@ class ComposeTerminalDisplay : TerminalDisplay { val bellTrigger: State = _bellTrigger val progressState: State = _progressState val progressValue: State = _progressValue + /** + * The app's OSC 2 window title. + * + * Empty is a RESET, not merely "nothing yet": TabController clears it at each command start + * so a title set by a program that has since exited stops naming the window. Consumers should + * fall back to something of their own rather than showing a blank - see + * `resolveWindowTitle` in TabbedTerminal, which falls back to the tab title. + */ val windowTitleFlow: StateFlow = _windowTitle.asStateFlow() val iconTitleFlow: StateFlow = _iconTitle.asStateFlow() diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 99cc45d2d..64b20ab73 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1649,7 +1649,7 @@ fun TabbedTerminal( val focused = splitState.getFocusedSession() ?: activeTab snapshotFlow { focused.customTitle.value to focused.title.value } .combine(focused.display.windowTitleFlow) { (custom, tabTitle), windowTitle -> - custom ?: windowTitle.ifEmpty { tabTitle } + resolveWindowTitle(custom, windowTitle, tabTitle) } .collect { newTitle -> if (newTitle.isNotEmpty()) { @@ -2748,3 +2748,18 @@ private fun remoteMcpMenuItems( ), ) } + +/** + * Which of the three candidate titles the OS window should show. + * + * Precedence, highest first: a Rename… custom title, then the app's OSC 2 window title, then the + * tab's own title. See the call site for why each one is where it is. + * + * @param osc2 the OSC 2 window title, where empty means "reset, fall back" rather than "blank" - + * TabController clears it at each command start so an exited program stops naming the + * window. + * @return empty only when every candidate is empty, which the caller suppresses rather than + * showing a nameless window. + */ +internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = + custom ?: osc2.ifEmpty { tabTitle } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 37f1073ef..48d8da311 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -150,9 +150,20 @@ class TabController( val titleResetListener = object : ai.rever.bossterm.terminal.model.CommandStateListener { override fun onPromptStarted() { session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) - // Clear the OSC 2 window title too, so an app that set one does not keep - // naming the window after it has exited. The window title falls back to the - // tab's own title whenever this is empty - see TabbedTerminal. + } + + // Clear the OSC 2 window title at COMMAND start (133;B), not prompt start, so an + // app that set one does not keep naming the window after it has exited. The window + // title falls back to the tab's own title while it is empty - see TabbedTerminal. + // + // Deliberately B and not A, which is where the tab title resets: shells emit their + // own OSC 2 from precmd, the very same hook that emits 133;A, and nothing here + // controls the order. oh-my-zsh registers omz_termsupport_precmd before a user's + // BossTerm snippet, so its sequence is OSC 1, OSC 2, 133;D, 133;A - clearing on A + // would wipe the title it had just set, on every prompt. At B the previous + // program's title is genuinely stale and a precmd-set one has already survived the + // whole prompt. + override fun onCommandStarted() { session.terminal.setWindowTitle("") } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index 64571bb67..e1bd750fd 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -3,7 +3,6 @@ package ai.rever.bossterm.compose.window import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import java.awt.Rectangle import java.awt.Window import javax.swing.JRootPane import javax.swing.RootPaneContainer @@ -67,6 +66,15 @@ internal fun applyFullWindowContent( return runCatching { rootPane.putClientProperty("apple.awt.fullWindowContent", true) + // Caveat, and the one real cost of the transparent strip: AppKit draws the title text in + // the colour its EFFECTIVE APPEARANCE dictates, not one picked to contrast with whatever + // shows through. The packaged app runs with -Dapple.awt.application.appearance=system + // (bossterm-app/build.gradle.kts), so in macOS Light Mode the title is drawn near-black + // over the terminal background - which is dark by default. There is no supported per-window + // appearance client property (CPlatformWindow honours only the three set here, plus + // fullscreenable and some fade/shadow keys), so fixing it means either forcing the NSWindow + // appearance through JNA or hiding the system title and drawing it in the reserved strip. + // Left as-is deliberately: this is the native title bar, and the native title is part of it. rootPane.putClientProperty("apple.awt.transparentTitleBar", true) // The title stays visible. It is drawn centred in the title bar strip, and callers reserve // exactly that strip with NATIVE_TITLE_BAR_HEIGHT, so there is nothing for it to overlap @@ -81,26 +89,3 @@ internal fun applyFullWindowContent( false } } - -/** - * Whether the window is in macOS native fullscreen, where the system hides the title bar entirely - * and the caller must NOT reserve [NATIVE_TITLE_BAR_HEIGHT]. - * - * Measured from the window's own bounds rather than read from `WindowState.placement`. Compose - * Desktop syncs `placement` back from AWT's `extendedState`, and macOS native fullscreen (the green - * traffic light) is not an `extendedState` transition, so `placement` can stay `Floating` right - * through it - leaving the dead band at the top that the inset gate exists to prevent. - * - * The discriminator is the menu bar. Fullscreen covers the WHOLE display; zoom (Maximized) only - * fills the visible frame, leaving the menu bar and the Dock. So an exact match against the screen - * bounds separates the two, which is precisely the distinction that matters here: zoom keeps its - * title bar and must keep its inset. - */ -internal fun isNativeFullscreen(windowBounds: Rectangle?, screenBounds: Rectangle?): Boolean { - if (windowBounds == null || screenBounds == null) return false - return windowBounds == screenBounds -} - -/** @see isNativeFullscreen */ -fun isNativeFullscreen(window: Window): Boolean = - isNativeFullscreen(window.bounds, window.graphicsConfiguration?.bounds) diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt new file mode 100644 index 000000000..53106a082 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt @@ -0,0 +1,52 @@ +package ai.rever.bossterm.compose + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Which title the OS window shows, which is the behaviour that changed for every user: the window + * used to track OSC 2 alone and sat on its startup name for the shells that never emit it. + * + * The three candidates are deliberately NOT interchangeable. A Rename… is the user talking and + * outranks anything a program says; OSC 2 is what an app sets for the WINDOW specifically (xterm's + * split, where OSC 1 names the tab instead); the tab title is the shared fallback that keeps the + * title bar and the tab bar agreeing when nothing more specific has been said. + */ +class WindowTitleResolutionTest { + @Test + fun `a renamed tab outranks whatever the app called the window`() { + assertEquals( + "deploy", + resolveWindowTitle(custom = "deploy", osc2 = "me@host: ~/src", tabTitle = "src"), + ) + } + + @Test + fun `an app's OSC 2 title outranks the tab title`() { + // The case OSC 2 exists for: an app naming the window something longer or more specific + // than the tab label beside it. + assertEquals( + "me@host: ~/src", + resolveWindowTitle(custom = null, osc2 = "me@host: ~/src", tabTitle = "src"), + ) + } + + @Test + fun `an empty OSC 2 title falls back to the tab title`() { + // Empty is the RESET written at each command start, not a real title - without the + // fallback an exited program would leave the window nameless. + assertEquals("src", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "src")) + } + + @Test + fun `a rename still wins once the app title has been reset`() { + assertEquals("deploy", resolveWindowTitle(custom = "deploy", osc2 = "", tabTitle = "src")) + } + + @Test + fun `nothing to show stays empty for the caller to suppress`() { + // The collector guards on isNotEmpty, so this must not invent a placeholder - that guard + // is what stops a window being retitled to nothing during startup. + assertEquals("", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "")) + } +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt index 5d8d4a09c..56ab47ee4 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -1,7 +1,5 @@ package ai.rever.bossterm.compose.window -import androidx.compose.ui.unit.dp -import java.awt.Rectangle import javax.swing.JRootPane import kotlin.test.Test import kotlin.test.assertEquals @@ -55,47 +53,4 @@ class NativeTitleBarStyleTest { assertFalse(applyFullWindowContent(rootPane, isMacOS = false)) assertNull(rootPane.getClientProperty("apple.awt.fullWindowContent")) } - - @Test - fun `the reserved strip is the standard macOS title bar height`() { - // A constant rather than a measurement: with fullWindowContent the content pane fills the - // frame, so the usual "window height minus content height" reports zero. 28pt is what the - // traffic lights are laid out against for a regular-size window. - assertEquals(28.dp, NATIVE_TITLE_BAR_HEIGHT) - } - - // ---- isNativeFullscreen: bounds are the signal, because placement may not be ---- - - @Test - fun `covering the whole display reads as fullscreen`() { - // macOS native fullscreen takes the entire screen, menu bar strip included, so the - // window's bounds match the display's exactly. - val screen = Rectangle(0, 0, 1920, 1080) - assertTrue(isNativeFullscreen(Rectangle(0, 0, 1920, 1080), screen)) - } - - @Test - fun `a zoomed window is not fullscreen and keeps its inset`() { - // This is the case the whole function exists to separate. macOS zoom (the green button - // WITHOUT fullscreen, or Compose's Maximized) fills the VISIBLE frame - the menu bar is - // still there, so the window starts below it and is shorter than the display. The title - // bar is still on screen, so the caller must still reserve the strip. - val screen = Rectangle(0, 0, 1920, 1080) - val zoomedBelowTheMenuBar = Rectangle(0, 25, 1920, 1055) - assertFalse(isNativeFullscreen(zoomedBelowTheMenuBar, screen)) - } - - @Test - fun `an ordinary floating window is not fullscreen`() { - assertFalse(isNativeFullscreen(Rectangle(120, 80, 1200, 800), Rectangle(0, 0, 1920, 1080))) - } - - @Test - fun `unknown bounds are not fullscreen`() { - // A window with no graphics configuration yet must not be guessed as fullscreen: that - // would drop the inset and put the tab bar under the traffic lights. - val screen = Rectangle(0, 0, 1920, 1080) - assertFalse(isNativeFullscreen(null, screen)) - assertFalse(isNativeFullscreen(Rectangle(0, 0, 1920, 1080), null)) - } } From 58d0aeeefce46e301eb6b7b892a6065b73f6ca7c Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 10:43:27 -0700 Subject: [PATCH 07/12] Correct the hook-ordering premise and keep the title reset internal Fifth review pass, all four on the comments and the reset path. The rationale for clearing at 133;B claimed oh-my-zsh registers its precmd hook first. It is the other way round for the shipped integration, which is sourced from .zshenv and so registers ahead of anything .zshrc adds. Rewritten to say what is actually true: neither hook is order-safe in general, our own setup makes either choice safe, and B is the one that loses less when a user wires the snippet up after oh-my-zsh. Also noted that the reset needs OSC 133 at all, so inside tmux/screen or with no integration a title still outlives its program, which the previous wording claimed unconditionally. The reset now writes display.windowTitle directly instead of going through terminal.setWindowTitle. That call fans out to every application-title listener before touching the display, so internal bookkeeping was being published as though the program had set an empty title - including to EmbeddableTerminal's public onTitleChange, which survived it only by way of an isNotEmpty() guard. Writing the display keeps it internal and leaves the XTWINOPS title stack alone. One comment in TabbedTerminal still said prompt start; fixed. --- .../compose/ComposeTerminalDisplay.kt | 3 ++ .../rever/bossterm/compose/TabbedTerminal.kt | 6 ++-- .../bossterm/compose/tabs/TabController.kt | 33 ++++++++++++------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt index 08dbca1d4..817db083a 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt @@ -136,6 +136,9 @@ class ComposeTerminalDisplay : TerminalDisplay { * so a title set by a program that has since exited stops naming the window. Consumers should * fall back to something of their own rather than showing a blank - see * `resolveWindowTitle` in TabbedTerminal, which falls back to the tab title. + * + * The reset needs OSC 133, so it does not happen inside tmux/screen or in a session with no + * shell integration; there a title outlives the program that set it, as it always has. */ val windowTitleFlow: StateFlow = _windowTitle.asStateFlow() val iconTitleFlow: StateFlow = _iconTitle.asStateFlow() diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 64b20ab73..6490edbeb 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1642,9 +1642,9 @@ fun TabbedTerminal( // // But OSC 2 still wins when an app does set it, because that is what it is FOR // (xterm's split: OSC 1 names the tab, OSC 2 names the window) and some apps - // deliberately give the window a longer string than the tab. The prompt-reset - // listener in TabController clears it on each fresh prompt, so it reverts on exit - // exactly like the tab title does. + // deliberately give the window a longer string than the tab. TabController clears it + // at each COMMAND start, so it reverts once the app that set it exits - wherever OSC + // 133 reaches, which is not inside tmux/screen or without the shell integration. LaunchedEffect(activeTab, splitState.focusedPaneId) { val focused = splitState.getFocusedSession() ?: activeTab snapshotFlow { focused.customTitle.value to focused.title.value } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 48d8da311..fdaa0acec 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -152,19 +152,30 @@ class TabController( session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) } - // Clear the OSC 2 window title at COMMAND start (133;B), not prompt start, so an - // app that set one does not keep naming the window after it has exited. The window - // title falls back to the tab's own title while it is empty - see TabbedTerminal. + // Clear the OSC 2 window title at COMMAND start (133;B) so an app that set one does + // not keep naming the window after it has exited. The window title falls back to the + // tab's own title while it is empty - see resolveWindowTitle in TabbedTerminal. // - // Deliberately B and not A, which is where the tab title resets: shells emit their - // own OSC 2 from precmd, the very same hook that emits 133;A, and nothing here - // controls the order. oh-my-zsh registers omz_termsupport_precmd before a user's - // BossTerm snippet, so its sequence is OSC 1, OSC 2, 133;D, 133;A - clearing on A - // would wipe the title it had just set, on every prompt. At B the previous - // program's title is genuinely stale and a precmd-set one has already survived the - // whole prompt. + // B rather than A (where the tab title resets) because neither hook is order-safe in + // general and B loses less. Shells emit their own OSC 2 from the very hooks that emit + // 133;A and 133;B, and nothing here controls registration order: our bundled + // integration is sourced from .zshenv so its hooks run FIRST and either choice is + // safe, but a user who wires the snippet up from .zshrc after oh-my-zsh gets the + // reverse, and then whichever hook we clear on wipes a title omz had just set. At B + // the worst case is losing a per-command title for the length of one command; at A it + // would be the prompt title, every prompt. Both degrade to the tab title rather than + // to a blank window, which is what makes either survivable. + // + // Only fires where OSC 133 reaches: the bundled integration returns early inside + // tmux/screen and for TERM=dumb, and plenty of sessions have no integration at all. + // There the title still lingers after the app exits, exactly as it does today. override fun onCommandStarted() { - session.terminal.setWindowTitle("") + // The display, not terminal.setWindowTitle: this is internal bookkeeping, and + // going through the terminal would publish it to every application-title + // listener as though the program itself had set an empty title. That includes + // EmbeddableTerminal's public onTitleChange callback, which only survives it by + // way of an isNotEmpty() guard. It also keeps the XTWINOPS title stack out of it. + session.display.windowTitle = "" } } session.terminal.addCommandStateListener(titleResetListener) From 5c39d97c3efb22cdab84ab962828db3b426892ed Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 11:00:39 -0700 Subject: [PATCH 08/12] Clear the title at prompt start again, and name the session in notifications Sixth review pass. The clear went back to prompt start, which fixes a regression I had introduced two commits ago. Clearing at command start left the OSC 2 title empty for the whole DURATION of every command on shells that set no per-command title, and the completion notification reads that value at command finish - so notifications degraded to the literal "BossTerm" for exactly the users they matter to, since they only fire when the window is unfocused and several tabs would all say the same word. Prompt start does not have that window, and the reason I moved away from it was the hook-ordering premise that the previous pass already corrected: our integration is sourced from .zshenv, so its hooks run first and a shell's own precmd OSC 2 lands just after the clear and survives it. The window is untitled only for the instant in between. The residual reversed-order hazard is symmetric and is now written down as such rather than used to justify one hook over the other. Notifications now prefer the OSC 2 title, then the OSC 1 icon title, before falling back to the app name, so an unfocused tab says which session finished. Structural, from the same review: - titleBarInset is a real function with the Fullscreen-yes / Maximized-no rationale in its KDoc and three tests. It was the subtlest decision in the change and the only one with no coverage, in a predicate that had already drifted between two copies. - the client-property write moved from remember into SideEffect, with the predicate staying pure so the inset is still right on the first frame. A discarded composition no longer decides whether a window got styled. - distinctUntilChanged on the combined title flow: combine re-emits when either side changes and a rename writes two snapshot values, while window.title bottoms out in an AppKit call. - resolveWindowTitle treats a blank custom title as absent rather than letting whitespace win and blank the window. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 45 ++++------- .../rever/bossterm/compose/TabbedTerminal.kt | 8 +- .../bossterm/compose/tabs/TabController.kt | 78 ++++++++++++------- .../compose/window/NativeTitleBarStyle.kt | 28 +++++++ .../compose/window/NativeTitleBarStyleTest.kt | 27 +++++++ 5 files changed, 128 insertions(+), 58 deletions(-) diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 434ebd5e1..996e6c4ce 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -34,8 +34,8 @@ import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar -import ai.rever.bossterm.compose.window.NATIVE_TITLE_BAR_HEIGHT import ai.rever.bossterm.compose.window.applyFullWindowContent +import ai.rever.bossterm.compose.window.titleBarInset import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig import ai.rever.bossterm.compose.window.WindowManager @@ -293,15 +293,18 @@ fun main(args: Array) { // content. Only meaningful on the native path - the custom title bar already // owns that area. `this@Window.window` because the loop variable above shadows // FrameWindowScope.window. - // remember rather than SideEffect on purpose: this has to run before the - // content below measures, so the inset is right on the very first frame - // instead of the window jumping once. The write is an idempotent client - // property on a stable window, so a discarded composition costs nothing. - // Keyed on Unit because useNativeTitleBar is itself captured once at startup. + // Split deliberately: whether the content WILL run under the title bar is a + // pure predicate, so the inset below is right on the very first frame, while + // the client-property write that makes it so is a mutation and belongs in the + // effect phase. (A composition can be discarded or re-run; remember's + // initializer is not guaranteed to belong to the one that gets applied.) val fullWindowContent = - remember { - useNativeTitleBar && applyFullWindowContent(this@Window.window) + remember { useNativeTitleBar && ShellCustomizationUtils.isMacOS() } + SideEffect { + if (fullWindowContent) { + applyFullWindowContent(this@Window.window) } + } // Update manager state val updateManager = remember { UpdateManager.instance } @@ -748,28 +751,10 @@ fun main(args: Array) { windowState.placement == WindowPlacement.Maximized val cornerRadius = if (useNativeTitleBar || isFullscreenOrMaximized) 0.dp else 20.dp - // How much of the top the native title bar is covering right now, as ONE value - // for every site that has to stay clear of it. Two copies of this predicate - // drifted apart once already: the spacer handled fullscreen and the hotkey hint - // did not, which put the hint inside the tab bar row. - // - // Fullscreen ONLY, never Maximized: macOS zoom keeps its title bar, so a zoomed - // window still needs the inset. placement is trustworthy here, traced rather - // than assumed - Compose syncs it in a componentResized handler specifically - // because "fullscreen changing doesn't fire windowStateChanged, only - // componentResized" (SwingWindow.desktop.kt), and the value it reads bottoms out - // in skiko's osxIsFullscreenNative, i.e. the real NSWindow state rather than a - // flag set only when WE request fullscreen. So the green button is covered. - // A bounds-vs-screen heuristic was tried instead and removed: it cannot tell - // fullscreen from a zoomed window once the menu bar and Dock auto-hide, and - // guessing "fullscreen" there would drop the inset and slide the tab bar under - // the traffic lights. - val titleBarInset = - if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) { - NATIVE_TITLE_BAR_HEIGHT - } else { - 0.dp - } + // One value for every site that has to stay clear of the title bar. See + // titleBarInset for why it is Fullscreen-only, why Maximized still insets, and + // why placement is trusted for this. + val titleBarInset = titleBarInset(fullWindowContent, windowState.placement) // Load background image if set val backgroundImage = remember(windowSettings.backgroundImagePath) { diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 6490edbeb..08833de43 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1651,6 +1651,10 @@ fun TabbedTerminal( .combine(focused.display.windowTitleFlow) { (custom, tabTitle), windowTitle -> resolveWindowTitle(custom, windowTitle, tabTitle) } + // combine re-emits when EITHER side changes, and a rename writes customTitle + // and title as two separate snapshot writes. window.title bottoms out in an + // AppKit call, so only pass on actual changes. + .distinctUntilChanged() .collect { newTitle -> if (newTitle.isNotEmpty()) { onWindowTitleChange(newTitle) @@ -2762,4 +2766,6 @@ private fun remoteMcpMenuItems( * showing a nameless window. */ internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = - custom ?: osc2.ifEmpty { tabTitle } + // ifBlank, not just null: both rename paths normalise blank to null today, but a whitespace + // custom title would otherwise win and blank the window rather than falling through. + custom?.ifBlank { null } ?: osc2.ifEmpty { tabTitle } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index fdaa0acec..582f46a09 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -150,31 +150,34 @@ class TabController( val titleResetListener = object : ai.rever.bossterm.terminal.model.CommandStateListener { override fun onPromptStarted() { session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) - } - // Clear the OSC 2 window title at COMMAND start (133;B) so an app that set one does - // not keep naming the window after it has exited. The window title falls back to the - // tab's own title while it is empty - see resolveWindowTitle in TabbedTerminal. - // - // B rather than A (where the tab title resets) because neither hook is order-safe in - // general and B loses less. Shells emit their own OSC 2 from the very hooks that emit - // 133;A and 133;B, and nothing here controls registration order: our bundled - // integration is sourced from .zshenv so its hooks run FIRST and either choice is - // safe, but a user who wires the snippet up from .zshrc after oh-my-zsh gets the - // reverse, and then whichever hook we clear on wipes a title omz had just set. At B - // the worst case is losing a per-command title for the length of one command; at A it - // would be the prompt title, every prompt. Both degrade to the tab title rather than - // to a blank window, which is what makes either survivable. - // - // Only fires where OSC 133 reaches: the bundled integration returns early inside - // tmux/screen and for TERM=dumb, and plenty of sessions have no integration at all. - // There the title still lingers after the app exits, exactly as it does today. - override fun onCommandStarted() { - // The display, not terminal.setWindowTitle: this is internal bookkeeping, and - // going through the terminal would publish it to every application-title - // listener as though the program itself had set an empty title. That includes - // EmbeddableTerminal's public onTitleChange callback, which only survives it by - // way of an isNotEmpty() guard. It also keeps the XTWINOPS title stack out of it. + // Clear the OSC 2 window title here too, so an app that set one stops naming the + // window once it exits. Falls back to the tab's own title while empty - see + // resolveWindowTitle in TabbedTerminal. + // + // Prompt start, NOT command start. Both were tried. The deciding fact is that our + // shell integration is sourced from .zshenv, so its hooks are registered before + // anything .zshrc adds and therefore run FIRST: a shell that sets its own OSC 2 from + // precmd (oh-my-zsh does) emits it just AFTER this clear, so its title survives and + // the window is untitled only for the instant in between. Clearing at 133;B instead + // leaves the title empty for the whole DURATION of every command on shells that set + // no per-command title - which also fed "BossTerm" to the completion notification, + // whose whole job is saying which session finished. + // + // The residual hazard is symmetric and unavoidable: a user who registers the snippet + // from .zshrc AFTER oh-my-zsh gets the reverse order, and then this clear discards a + // title precmd had just set. It degrades to the tab title rather than to a blank, and + // no hook is order-safe in general. + // + // Only fires where OSC 133 reaches: the bundled integration returns early inside + // tmux/screen and for TERM=dumb, and plenty of sessions have no integration at all. + // There a title still outlives the program that set it, exactly as it does today. + // + // The display, not terminal.setWindowTitle: this is internal bookkeeping, and going + // through the terminal would publish it to every application-title listener as though + // the program had set an empty title. That includes EmbeddableTerminal's public + // onTitleChange, which only survives it by way of an isNotEmpty() guard. It also + // keeps the XTWINOPS title stack out of it. session.display.windowTitle = "" } } @@ -550,7 +553,14 @@ class TabController( val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { "BossTerm" } ?: "BossTerm" } + // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. + // A notification only fires when the window is UNFOCUSED, so its whole job is saying + // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + tabTitle = { + display.windowTitle?.ifEmpty { null } + ?: display.iconTitle?.ifEmpty { null } + ?: "BossTerm" + } ) terminal.addCommandStateListener(notificationHandler) @@ -935,7 +945,14 @@ class TabController( val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { sessionTitle } ?: sessionTitle } + // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. + // A notification only fires when the window is UNFOCUSED, so its whole job is saying + // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + tabTitle = { + display.windowTitle?.ifEmpty { null } + ?: display.iconTitle?.ifEmpty { null } + ?: sessionTitle + } ) terminal.addCommandStateListener(notificationHandler) @@ -1181,7 +1198,14 @@ class TabController( val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = { display.windowTitle?.ifEmpty { "BossTerm" } ?: "BossTerm" } + // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. + // A notification only fires when the window is UNFOCUSED, so its whole job is saying + // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + tabTitle = { + display.windowTitle?.ifEmpty { null } + ?: display.iconTitle?.ifEmpty { null } + ?: "BossTerm" + } ) terminal.addCommandStateListener(notificationHandler) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index e1bd750fd..5ff3f4d17 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -3,6 +3,7 @@ package ai.rever.bossterm.compose.window import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement import java.awt.Window import javax.swing.JRootPane import javax.swing.RootPaneContainer @@ -89,3 +90,30 @@ internal fun applyFullWindowContent( false } } + +/** + * How much of the top the native title bar is covering, and therefore how far content must be + * pushed down to stay clear of the traffic lights. Zero whenever nothing needs reserving, so + * callers can apply it unconditionally instead of repeating the predicate. + * + * Repeating it is exactly what went wrong once already: two copies drifted, one of them handled + * fullscreen and the other did not, and the un-inset one rendered inside the title bar strip. + * + * Fullscreen ONLY, never Maximized. macOS zoom keeps its title bar, so a zoomed window still needs + * the inset; native fullscreen hides the bar entirely, so reserving there would leave a dead band + * of background above the content. + * + * [placement] is trustworthy for this, traced rather than assumed: Compose syncs it from a + * `componentResized` handler specifically because "fullscreen changing doesn't fire + * windowStateChanged, only componentResized", and the value it reads bottoms out in skiko's + * `osxIsFullscreenNative` - the real NSWindow state, not a flag set only when we request + * fullscreen. So a fullscreen entered from the green traffic light is covered. A bounds-vs-screen + * heuristic was tried in its place and removed: it cannot tell fullscreen from a zoomed window + * once the menu bar and Dock auto-hide, and guessing wrong there slides the tab bar under the + * traffic lights. + * + * @param styleApplied what [applyFullWindowContent] returned. False means the platform is drawing + * its own title bar above the content, which needs no inset at all. + */ +fun titleBarInset(styleApplied: Boolean, placement: WindowPlacement): Dp = + if (styleApplied && placement != WindowPlacement.Fullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt index 56ab47ee4..14ac37988 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -1,5 +1,7 @@ package ai.rever.bossterm.compose.window +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement import javax.swing.JRootPane import kotlin.test.Test import kotlin.test.assertEquals @@ -53,4 +55,29 @@ class NativeTitleBarStyleTest { assertFalse(applyFullWindowContent(rootPane, isMacOS = false)) assertNull(rootPane.getClientProperty("apple.awt.fullWindowContent")) } + + // ---- titleBarInset: the subtlest decision here, and the one that already drifted ---- + + @Test + fun `a zoomed window still reserves the strip`() { + // The distinction the whole function exists for. macOS zoom KEEPS its title bar, so a + // maximized window must still inset or the tab bar slides under the traffic lights. + assertEquals(NATIVE_TITLE_BAR_HEIGHT, titleBarInset(true, WindowPlacement.Maximized)) + assertEquals(NATIVE_TITLE_BAR_HEIGHT, titleBarInset(true, WindowPlacement.Floating)) + } + + @Test + fun `fullscreen reserves nothing because the title bar is gone`() { + // Reserving here would leave a dead band of background above the content. + assertEquals(0.dp, titleBarInset(true, WindowPlacement.Fullscreen)) + } + + @Test + fun `without the style applied nothing is reserved in any placement`() { + // Off macOS, or for a window that could not be styled: the platform draws its own title + // bar above the content, so an inset would be pure dead space. + for (placement in WindowPlacement.entries) { + assertEquals(0.dp, titleBarInset(false, placement), "unstyled $placement must not inset") + } + } } From 9b4d353136db0ebd76491f51ed1f364a8b533b84 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 11:11:13 -0700 Subject: [PATCH 09/12] Wire the decline path through, and stop notifications naming a dead program Seventh review pass. applyFullWindowContent's return value was documented as the thing that decides whether the caller insets, and the tests said so too, but the call site threw it away and insetted off the predicate instead. So on the decline path the function exists for, the window would still have reserved 28dp for a title bar that was never styled. The result now drives the inset, starting optimistic so the first frame is still right. Notifications fell back to display.iconTitle, which nothing ever resets: quit vim, start a long build in an unfocused window, and the completion notification said "vim". That is the same staleness this PR fixes for OSC 2, one slot down. They now resolve through the tab with the same precedence as the window title, so the two agree on which session finished, and a shell that sets no title at all gets the cwd instead of every tab announcing the app name. Also: osc2 uses ifBlank like custom already did, since a whitespace title wins and leaves the window looking nameless for exactly the same reason; the XTWINOPS note is corrected (the clear does not TRIGGER the title stack, but saveWindowTitleOnStack reads the same field, so it does not hide from it either); the apply moved to LaunchedEffect keyed on the predicate, which says once-per-window rather than once-per-recomposition; and the inset val is renamed so it no longer shadows the function it calls. Four new tests on the notification precedence and the whitespace cases. --- .../kotlin/ai/rever/bossterm/app/Main.kt | 16 +++-- .../rever/bossterm/compose/TabbedTerminal.kt | 27 ++++++++- .../bossterm/compose/tabs/TabController.kt | 60 +++++++++++++------ .../compose/WindowTitleResolutionTest.kt | 42 +++++++++++++ 4 files changed, 118 insertions(+), 27 deletions(-) diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 996e6c4ce..4faacb4b2 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -300,9 +300,15 @@ fun main(args: Array) { // initializer is not guaranteed to belong to the one that gets applied.) val fullWindowContent = remember { useNativeTitleBar && ShellCustomizationUtils.isMacOS() } - SideEffect { + // Starts optimistic so the inset is right on frame one, then takes the actual + // result: applyFullWindowContent declines a window it cannot style, and the + // inset has to follow it or we reserve 28dp for a title bar that stayed where + // it was. ComposeWindow is a JFrame so the decline path should never fire - + // this is what makes that a fact about the code rather than a hope. + var styleApplied by remember { mutableStateOf(fullWindowContent) } + LaunchedEffect(fullWindowContent) { if (fullWindowContent) { - applyFullWindowContent(this@Window.window) + styleApplied = applyFullWindowContent(this@Window.window) } } @@ -754,7 +760,7 @@ fun main(args: Array) { // One value for every site that has to stay clear of the title bar. See // titleBarInset for why it is Fullscreen-only, why Maximized still insets, and // why placement is trusted for this. - val titleBarInset = titleBarInset(fullWindowContent, windowState.placement) + val topInset = titleBarInset(styleApplied, windowState.placement) // Load background image if set val backgroundImage = remember(windowSettings.backgroundImagePath) { @@ -842,7 +848,7 @@ fun main(args: Array) { // lights and be unclickable. The app's background already paints // through this strip, which is the point. Zero in fullscreen; see // titleBarInset. - Spacer(modifier = Modifier.height(titleBarInset)) + Spacer(modifier = Modifier.height(topInset)) // Custom title bar (only when not using native title bar) if (!useNativeTitleBar) { @@ -946,7 +952,7 @@ fun main(args: Array) { Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = titleBarInset + 8.dp, end = 12.dp) + .padding(top = topInset + 8.dp, end = 12.dp) ) { Text( text = globalHotkeyHint, diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 08833de43..36338ba3e 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -2760,12 +2760,33 @@ private fun remoteMcpMenuItems( * tab's own title. See the call site for why each one is where it is. * * @param osc2 the OSC 2 window title, where empty means "reset, fall back" rather than "blank" - - * TabController clears it at each command start so an exited program stops naming the - * window. + * TabController clears it at each prompt start so an exited program stops naming the window. + * Blank counts as empty for the same reason a blank rename does: a whitespace title would + * otherwise win and leave the window looking nameless. * @return empty only when every candidate is empty, which the caller suppresses rather than * showing a nameless window. */ internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = // ifBlank, not just null: both rename paths normalise blank to null today, but a whitespace // custom title would otherwise win and blank the window rather than falling through. - custom?.ifBlank { null } ?: osc2.ifEmpty { tabTitle } + custom?.ifBlank { null } ?: osc2.ifBlank { tabTitle } + +/** + * The title a command-completion notification carries. + * + * Same precedence as the window title, so the two agree on which session finished - which is the + * notification's entire job, since it only fires while the window is UNFOCUSED and several tabs all + * announcing the app name says nothing. + * + * @param tabTitle the tab's resolved title. This is the one that makes the fallback useful: it is + * re-asserted at every prompt, mirrors an app's OSC 1, and otherwise reads the cwd - so it + * neither goes stale like a raw OSC 1 slot (which nothing resets, and would still say "vim" + * long after vim exited) nor collapses to the app name on a shell that sets no title at all. + * @param fallback used only when there is genuinely nothing to say. + */ +internal fun notificationTitle( + custom: String?, + osc2: String, + tabTitle: String, + fallback: String, +): String = resolveWindowTitle(custom, osc2, tabTitle).ifBlank { fallback } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 582f46a09..5022d97f9 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay +import ai.rever.bossterm.compose.notificationTitle import ai.rever.bossterm.compose.ConnectionState import ai.rever.bossterm.compose.PlatformServices import ai.rever.bossterm.compose.putBossTermGraphicsEnvironment @@ -177,7 +178,10 @@ class TabController( // through the terminal would publish it to every application-title listener as though // the program had set an empty title. That includes EmbeddableTerminal's public // onTitleChange, which only survives it by way of an isNotEmpty() guard. It also - // keeps the XTWINOPS title stack out of it. + // does not trigger the XTWINOPS title stack. (It does not hide from it either - + // saveWindowTitleOnStack reads this same field, so an app pushing a title around + // itself right after a prompt pushes "", as it already did on any shell that never + // emitted OSC 2.) session.display.windowTitle = "" } } @@ -550,16 +554,21 @@ class TabController( // Register command state listener for notifications (OSC 133 shell integration). // Also captured in `tab.commandStateListeners` after construction so dispose() // can remove it (see TerminalTab.commandStateListeners docs). + var tabRef: TerminalTab? = null val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. - // A notification only fires when the window is UNFOCUSED, so its whole job is saying - // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + // Same precedence as the window title, so the two agree on which session finished. + // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so + // it would still say "vim" during the next long build. tabRef is assigned right after + // the tab is built below; the lambda is only invoked at command finish. tabTitle = { - display.windowTitle?.ifEmpty { null } - ?: display.iconTitle?.ifEmpty { null } - ?: "BossTerm" + notificationTitle( + custom = tabRef?.customTitle?.value, + osc2 = display.windowTitle.orEmpty(), + tabTitle = tabRef?.title?.value.orEmpty(), + fallback = "BossTerm", + ) } ) terminal.addCommandStateListener(notificationHandler) @@ -647,6 +656,7 @@ class TabController( // them when the tab closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) + tabRef = tab tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -942,16 +952,21 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) + var tabRef: TerminalTab? = null val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. - // A notification only fires when the window is UNFOCUSED, so its whole job is saying - // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + // Same precedence as the window title, so the two agree on which session finished. + // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so + // it would still say "vim" during the next long build. tabRef is assigned right after + // the tab is built below; the lambda is only invoked at command finish. tabTitle = { - display.windowTitle?.ifEmpty { null } - ?: display.iconTitle?.ifEmpty { null } - ?: sessionTitle + notificationTitle( + custom = tabRef?.customTitle?.value, + osc2 = display.windowTitle.orEmpty(), + tabTitle = tabRef?.title?.value.orEmpty(), + fallback = sessionTitle, + ) } ) terminal.addCommandStateListener(notificationHandler) @@ -1038,6 +1053,7 @@ class TabController( // pane closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(session) terminal.addCommandStateListener(lastCommandTracker) + tabRef = session session.commandStateListeners.add(notificationHandler) session.commandStateListeners.add(lastCommandTracker) @@ -1195,16 +1211,21 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) + var tabRef: TerminalTab? = null val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Prefer the app's own OSC 2 title, then its OSC 1 icon title, before the app name. - // A notification only fires when the window is UNFOCUSED, so its whole job is saying - // WHICH session finished - several tabs all announcing "BossTerm" says nothing. + // Same precedence as the window title, so the two agree on which session finished. + // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so + // it would still say "vim" during the next long build. tabRef is assigned right after + // the tab is built below; the lambda is only invoked at command finish. tabTitle = { - display.windowTitle?.ifEmpty { null } - ?: display.iconTitle?.ifEmpty { null } - ?: "BossTerm" + notificationTitle( + custom = tabRef?.customTitle?.value, + osc2 = display.windowTitle.orEmpty(), + tabTitle = tabRef?.title?.value.orEmpty(), + fallback = "BossTerm", + ) } ) terminal.addCommandStateListener(notificationHandler) @@ -1261,6 +1282,7 @@ class TabController( // are recorded on the tab so dispose() can remove them. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) + tabRef = tab tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt index 53106a082..5e1d10610 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt @@ -49,4 +49,46 @@ class WindowTitleResolutionTest { // is what stops a window being retitled to nothing during startup. assertEquals("", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "")) } + + @Test + fun `a whitespace OSC 2 title falls through like a blank rename does`() { + // Same reasoning as the custom title: letting whitespace win leaves the window looking + // nameless, which is worse than the fallback it was covering up. + assertEquals("src", resolveWindowTitle(custom = null, osc2 = " ", tabTitle = "src")) + assertEquals("src", resolveWindowTitle(custom = " ", osc2 = "", tabTitle = "src")) + } + + // ---- the same precedence, as a completion notification sees it ---- + + @Test + fun `a notification names the session, not a program that already exited`() { + // The regression this guards: display.iconTitle is never reset, so falling back to it left + // a finished build announcing "vim" long after vim had quit. The tab title is re-asserted + // at each prompt, so it reverts. + assertEquals( + "src", + notificationTitle(custom = null, osc2 = "", tabTitle = "src", fallback = "BossTerm"), + ) + } + + @Test + fun `a notification prefers what the running program calls itself`() { + assertEquals( + "build.gradle.kts", + notificationTitle( + custom = null, + osc2 = "build.gradle.kts", + tabTitle = "src", + fallback = "BossTerm", + ), + ) + } + + @Test + fun `a notification falls back to the app name only with nothing to say`() { + assertEquals( + "BossTerm", + notificationTitle(custom = null, osc2 = "", tabTitle = "", fallback = "BossTerm"), + ) + } } From 6b0443a0ef62ccfef5deacf02ac1bbacb0c60c69 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 11:23:35 -0700 Subject: [PATCH 10/12] One notification-title provider with a safe slot, and record the facts Eighth review pass. The three copies of the notification-title lambda are now one NotificationTitleProvider. It also fixes a visibility hole the copies had: the tab was written to a captured var on the constructing thread and read on the terminal reader thread, where onCommandFinished dispatches from, with no happens-before edge between them. A plain captured var is a non-volatile field, so the reader was not guaranteed to see the assignment at all. AtomicReference gives that edge for nothing. Consolidating also stops a sibling of the very predicate whose KDoc records that duplicating it is what caused the last drift from existing in triplicate. The failure branch logs to stderr rather than stdout, which is the one channel a terminal emulator should leave alone. AGENTS.md gets the two facts that belong there rather than only in a KDoc: that AWT cannot give a decorated frame an alpha-capable backing store (measured, with the peer-level detail), and the OSC 1 = tab / OSC 2 = window split now that it is load-bearing across three files. NativeTitleBarStyle.kt is listed under Key Files. On the light-mode title (#368): the review suggested building on the ObjC bridge in window/WindowTransparency.kt as though it were ready to use. It is not - its only consumer is commented out there as having "compatibility issues with modern macOS/Java", so the binding is unproven rather than merely unused. The KDoc now points at it AND says that, so whoever picks up #368 starts with both halves. --- AGENTS.md | 35 ++++++++ .../bossterm/compose/tabs/TabController.kt | 85 ++++++++++--------- .../compose/window/NativeTitleBarStyle.kt | 6 +- 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e44aee12c..f5f168fa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,37 @@ fine and a clean build re-arms it. Note that querying windows via `osascript`/Sy macOS `dlopen` the accessibility bundles into the target - that adds a waiter on the very same lock and reports `0 windows` as a false negative. Use `jcmd`/`sample` instead. +### AWT cannot make a decorated frame transparent (measured) + +Do not spend an afternoon rediscovering this. AWT allocates an alpha-capable backing store only for +windows it considers translucent, and refuses that for decorated frames - +`IllegalComponentStateException: The frame is decorated`, on both `setBackground(alpha<255)` and +`setOpacity`, before and after the window is shown. Forcing the `NSWindow` non-opaque underneath is +not enough either: measured through the peer, `CPlatformWindow.setOpaque(false)` runs and the window +reports `isOpaque = NO`, and alpha still composites onto black because the surface has no alpha +channel. The same alpha-0 fill in an **undecorated** window is see-through. + +macOS itself allows it, which is how Terminal.app is transparent with traffic lights - it is AWT's +window that cannot be. So transparency belongs to the undecorated path only, which is exactly what +`useNativeTitleBar = false` selects. See `compose-ui/.../window/NativeTitleBarStyle.kt`. + +One consequence rides along: with `apple.awt.transparentTitleBar` AppKit draws the title text in the +colour the window's effective appearance dictates, not one picked to contrast with what shows +through - so a light system appearance draws a dark title over the dark terminal background +(issue #368). + +### OSC 1 names the TAB, OSC 2 names the WINDOW + +xterm's split, and it is load-bearing across `TabController`, `TabbedTerminal` and `ProperTerminal`. +Do not fold them into one field: apps set them to different strings deliberately (oh-my-zsh emits a +short OSC 1 and a long OSC 2 back to back from `precmd`), so merging makes the tab label depend on +which arrived last. OSC 0 sets both. + +The window title resolves `resolveWindowTitle`: a Rename… custom title, then OSC 2, then the tab's +own title. The OSC 2 slot is cleared at prompt start so a program that set one stops naming the +window after it exits - which needs OSC 133, so it does not happen inside tmux/screen or without the +shell integration. + ### Emoji Rendering Skia ignores variation selectors (U+FE0F). Peek-ahead to detect, switch to `FontFamily.Default`, render as unit. @@ -111,6 +142,10 @@ Located in: `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/shell/S - `bossterm-core-mpp/src/jvmMain/kotlin/com/bossterm/terminal/model/TerminalTextBuffer.kt` - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/pool/IncrementalSnapshotBuilder.kt` +**Window** +- `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt` + (full window content, the title bar inset, and the transparency finding above) + **Components** - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt` - `compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/EmbeddableTerminal.kt` diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 5022d97f9..0ff7a8e1e 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -17,6 +17,7 @@ import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay import ai.rever.bossterm.compose.notificationTitle +import java.util.concurrent.atomic.AtomicReference import ai.rever.bossterm.compose.ConnectionState import ai.rever.bossterm.compose.PlatformServices import ai.rever.bossterm.compose.putBossTermGraphicsEnvironment @@ -554,22 +555,11 @@ class TabController( // Register command state listener for notifications (OSC 133 shell integration). // Also captured in `tab.commandStateListeners` after construction so dispose() // can remove it (see TerminalTab.commandStateListeners docs). - var tabRef: TerminalTab? = null + val notificationTitle = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Same precedence as the window title, so the two agree on which session finished. - // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so - // it would still say "vim" during the next long build. tabRef is assigned right after - // the tab is built below; the lambda is only invoked at command finish. - tabTitle = { - notificationTitle( - custom = tabRef?.customTitle?.value, - osc2 = display.windowTitle.orEmpty(), - tabTitle = tabRef?.title?.value.orEmpty(), - fallback = "BossTerm", - ) - } + tabTitle = notificationTitle, ) terminal.addCommandStateListener(notificationHandler) @@ -656,7 +646,7 @@ class TabController( // them when the tab closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) - tabRef = tab + notificationTitle.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -952,22 +942,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) - var tabRef: TerminalTab? = null + val notificationTitle = NotificationTitleProvider(display, sessionTitle) val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Same precedence as the window title, so the two agree on which session finished. - // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so - // it would still say "vim" during the next long build. tabRef is assigned right after - // the tab is built below; the lambda is only invoked at command finish. - tabTitle = { - notificationTitle( - custom = tabRef?.customTitle?.value, - osc2 = display.windowTitle.orEmpty(), - tabTitle = tabRef?.title?.value.orEmpty(), - fallback = sessionTitle, - ) - } + tabTitle = notificationTitle, ) terminal.addCommandStateListener(notificationHandler) @@ -1053,7 +1032,7 @@ class TabController( // pane closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(session) terminal.addCommandStateListener(lastCommandTracker) - tabRef = session + notificationTitle.attach(session) session.commandStateListeners.add(notificationHandler) session.commandStateListeners.add(lastCommandTracker) @@ -1211,22 +1190,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) - var tabRef: TerminalTab? = null + val notificationTitle = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - // Same precedence as the window title, so the two agree on which session finished. - // Through the tab rather than off display.iconTitle: nothing ever resets that slot, so - // it would still say "vim" during the next long build. tabRef is assigned right after - // the tab is built below; the lambda is only invoked at command finish. - tabTitle = { - notificationTitle( - custom = tabRef?.customTitle?.value, - osc2 = display.windowTitle.orEmpty(), - tabTitle = tabRef?.title?.value.orEmpty(), - fallback = "BossTerm", - ) - } + tabTitle = notificationTitle, ) terminal.addCommandStateListener(notificationHandler) @@ -1282,7 +1250,7 @@ class TabController( // are recorded on the tab so dispose() can remove them. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) - tabRef = tab + notificationTitle.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -2208,3 +2176,36 @@ class TabController( } } } + +/** + * The title a [CommandNotificationHandler] announces, resolved the same way as the window title so + * the two agree on which session finished. + * + * Exists as a class rather than three lambdas because the tab does not exist yet when the handler + * is constructed, and because that ordering is not the only guarantee needed: the slot is written + * on the constructing thread and read on the terminal reader thread, where `onCommandFinished` + * dispatches from. A captured `var` compiles to a non-volatile field with no happens-before edge + * between the two, so the reader is not guaranteed to see the assignment at all. [AtomicReference] + * gives that edge for nothing. + */ +private class NotificationTitleProvider( + private val display: ComposeTerminalDisplay, + private val fallback: String, +) : () -> String { + private val tab = AtomicReference(null) + + /** Called once the tab exists; the lambda is not invoked before a command finishes. */ + fun attach(tab: TerminalTab) = this.tab.set(tab) + + override fun invoke(): String { + val tab = tab.get() + return notificationTitle( + custom = tab?.customTitle?.value, + osc2 = display.windowTitle.orEmpty(), + // Through the tab, not display.iconTitle: nothing ever resets that slot, so it would + // still say "vim" during the next long build. + tabTitle = tab?.title?.value.orEmpty(), + fallback = fallback, + ) + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index 5ff3f4d17..8f901d91b 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -76,6 +76,10 @@ internal fun applyFullWindowContent( // fullscreenable and some fade/shadow keys), so fixing it means either forcing the NSWindow // appearance through JNA or hiding the system title and drawing it in the reserved strip. // Left as-is deliberately: this is the native title bar, and the native title is part of it. + // Tracked in issue #368. The ObjC bridge to build the first option on is in this package, + // in window/WindowTransparency.kt - but note before assuming it is turnkey that its only + // consumer (configureMacOSBlur) is commented out there as having "compatibility issues + // with modern macOS/Java", so the binding is unproven rather than merely unused. rootPane.putClientProperty("apple.awt.transparentTitleBar", true) // The title stays visible. It is drawn centred in the title bar strip, and callers reserve // exactly that strip with NATIVE_TITLE_BAR_HEIGHT, so there is nothing for it to overlap @@ -86,7 +90,7 @@ internal fun applyFullWindowContent( // putClientProperty on a JRootPane essentially cannot throw, so reaching here means // something is badly wrong - exactly when a silent un-inset window would be the worst // outcome to debug. - println("NativeTitleBarStyle: could not apply full window content: $it") + System.err.println("NativeTitleBarStyle: could not apply full window content: $it") false } } From 8ada4ac659d5645bc7a5ff0dac71e3ccc601f27d Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 11:37:00 -0700 Subject: [PATCH 11/12] Make the docs agree with the hook the code actually uses Ninth review pass. Three comments still said the OSC 2 title is cleared at command start, including the KDoc on windowTitleFlow that a consumer reads to learn what empty means. The code clears at prompt start and argues at length for it, so a reader found both claims and had to work out which was true. Fixed, and the root cause with it: that argument existed in several copies, so AGENTS.md is now the one that carries it and the call site keeps the decision plus a pointer. Also from the review: - resolveWindowTitle and notificationTitle move out of the 2.8k-line composable file into WindowTitle.kt. They are title policy, not part of the composable. - the local NotificationTitleProvider val no longer shadows the imported notificationTitle function, and the AtomicReference import is no longer spliced into the middle of the ai.rever block. - NotificationTitleProvider is internal, with a test pinning what it answers before its tab is attached: the app's OSC 2 title if there is one, otherwise the fallback, which is exactly pre-PR behaviour. - runCatching becomes try/catch (e: Exception), so a CancellationException crossing the LaunchedEffect cannot be swallowed and reported as a style failure. - the TerminalSettings KDoc no longer links an overloaded name Dokka would warn on as ambiguous. Two findings are hand-checks in the running app rather than code, and are on the list for the manual pass: whether the title bar macOS reveals on a top-edge hover in fullscreen swallows clicks on the first tab row, and whether a TUI that rewrites its title continuously makes the window title churn. --- .../compose/ComposeTerminalDisplay.kt | 2 +- .../rever/bossterm/compose/TabbedTerminal.kt | 40 +----------- .../ai/rever/bossterm/compose/WindowTitle.kt | 39 ++++++++++++ .../compose/settings/TerminalSettings.kt | 2 +- .../bossterm/compose/tabs/TabController.kt | 62 +++++++------------ .../compose/window/NativeTitleBarStyle.kt | 6 +- .../compose/WindowTitleResolutionTest.kt | 20 +++++- 7 files changed, 86 insertions(+), 85 deletions(-) create mode 100644 compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt index 817db083a..d5de559b4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ComposeTerminalDisplay.kt @@ -132,7 +132,7 @@ class ComposeTerminalDisplay : TerminalDisplay { /** * The app's OSC 2 window title. * - * Empty is a RESET, not merely "nothing yet": TabController clears it at each command start + * Empty is a RESET, not merely "nothing yet": TabController clears it at each prompt start * so a title set by a program that has since exited stops naming the window. Consumers should * fall back to something of their own rather than showing a blank - see * `resolveWindowTitle` in TabbedTerminal, which falls back to the tab title. diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt index 36338ba3e..3afb10c70 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/TabbedTerminal.kt @@ -1643,7 +1643,7 @@ fun TabbedTerminal( // But OSC 2 still wins when an app does set it, because that is what it is FOR // (xterm's split: OSC 1 names the tab, OSC 2 names the window) and some apps // deliberately give the window a longer string than the tab. TabController clears it - // at each COMMAND start, so it reverts once the app that set it exits - wherever OSC + // at each PROMPT start, so it reverts once the app that set it exits - wherever OSC // 133 reaches, which is not inside tmux/screen or without the shell integration. LaunchedEffect(activeTab, splitState.focusedPaneId) { val focused = splitState.getFocusedSession() ?: activeTab @@ -2752,41 +2752,3 @@ private fun remoteMcpMenuItems( ), ) } - -/** - * Which of the three candidate titles the OS window should show. - * - * Precedence, highest first: a Rename… custom title, then the app's OSC 2 window title, then the - * tab's own title. See the call site for why each one is where it is. - * - * @param osc2 the OSC 2 window title, where empty means "reset, fall back" rather than "blank" - - * TabController clears it at each prompt start so an exited program stops naming the window. - * Blank counts as empty for the same reason a blank rename does: a whitespace title would - * otherwise win and leave the window looking nameless. - * @return empty only when every candidate is empty, which the caller suppresses rather than - * showing a nameless window. - */ -internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = - // ifBlank, not just null: both rename paths normalise blank to null today, but a whitespace - // custom title would otherwise win and blank the window rather than falling through. - custom?.ifBlank { null } ?: osc2.ifBlank { tabTitle } - -/** - * The title a command-completion notification carries. - * - * Same precedence as the window title, so the two agree on which session finished - which is the - * notification's entire job, since it only fires while the window is UNFOCUSED and several tabs all - * announcing the app name says nothing. - * - * @param tabTitle the tab's resolved title. This is the one that makes the fallback useful: it is - * re-asserted at every prompt, mirrors an app's OSC 1, and otherwise reads the cwd - so it - * neither goes stale like a raw OSC 1 slot (which nothing resets, and would still say "vim" - * long after vim exited) nor collapses to the app name on a shell that sets no title at all. - * @param fallback used only when there is genuinely nothing to say. - */ -internal fun notificationTitle( - custom: String?, - osc2: String, - tabTitle: String, - fallback: String, -): String = resolveWindowTitle(custom, osc2, tabTitle).ifBlank { fallback } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt new file mode 100644 index 000000000..581aa6be1 --- /dev/null +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/WindowTitle.kt @@ -0,0 +1,39 @@ +package ai.rever.bossterm.compose + +/** + * Which of the three candidate titles the OS window should show. + * + * Precedence, highest first: a Rename… custom title, then the app's OSC 2 window title, then the + * tab's own title. See the call site for why each one is where it is. + * + * @param osc2 the OSC 2 window title, where empty means "reset, fall back" rather than "blank" - + * TabController clears it at each prompt start so an exited program stops naming the window. + * Blank counts as empty for the same reason a blank rename does: a whitespace title would + * otherwise win and leave the window looking nameless. + * @return empty only when every candidate is empty, which the caller suppresses rather than + * showing a nameless window. + */ +internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String = + // ifBlank, not just null: both rename paths normalise blank to null today, but a whitespace + // custom title would otherwise win and blank the window rather than falling through. + custom?.ifBlank { null } ?: osc2.ifBlank { tabTitle } + +/** + * The title a command-completion notification carries. + * + * Same precedence as the window title, so the two agree on which session finished - which is the + * notification's entire job, since it only fires while the window is UNFOCUSED and several tabs all + * announcing the app name says nothing. + * + * @param tabTitle the tab's resolved title. This is the one that makes the fallback useful: it is + * re-asserted at every prompt, mirrors an app's OSC 1, and otherwise reads the cwd - so it + * neither goes stale like a raw OSC 1 slot (which nothing resets, and would still say "vim" + * long after vim exited) nor collapses to the app name on a shell that sets no title at all. + * @param fallback used only when there is genuinely nothing to say. + */ +internal fun notificationTitle( + custom: String?, + osc2: String, + tabTitle: String, + fallback: String, +): String = resolveWindowTitle(custom, osc2, tabTitle).ifBlank { fallback } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt index ecdc6a521..9685c58a0 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/settings/TerminalSettings.kt @@ -171,7 +171,7 @@ data class TerminalSettings( * Changing this requires app restart to take effect. * * "No transparency" is an AWT restriction, not a macOS one - see - * [ai.rever.bossterm.compose.window.applyFullWindowContent], which records what was measured. + * `applyFullWindowContent` in `window/NativeTitleBarStyle.kt`, which records what was measured. * Short version: AWT gives an alpha-capable backing store only to windows it treats as * translucent and refuses that for decorated frames, and forcing the NSWindow non-opaque * underneath does not help because the surface has no alpha channel. Do not spend an diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 0ff7a8e1e..ee0bcfdf4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -17,7 +17,6 @@ import ai.rever.bossterm.compose.vcs.GitUtils import ai.rever.bossterm.compose.ComposeQuestioner import ai.rever.bossterm.compose.ComposeTerminalDisplay import ai.rever.bossterm.compose.notificationTitle -import java.util.concurrent.atomic.AtomicReference import ai.rever.bossterm.compose.ConnectionState import ai.rever.bossterm.compose.PlatformServices import ai.rever.bossterm.compose.putBossTermGraphicsEnvironment @@ -43,6 +42,7 @@ import ai.rever.bossterm.compose.TerminalSession import ai.rever.bossterm.core.typeahead.TerminalTypeAheadManager import ai.rever.bossterm.core.typeahead.TypeAheadTerminalModel import ai.rever.bossterm.terminal.util.GraphemeBoundaryUtils +import java.util.concurrent.atomic.AtomicReference /** * Return the full index permutation for moving a tab only among [movableIndices]. @@ -153,36 +153,18 @@ class TabController( override fun onPromptStarted() { session.title.value = session.customTitle.value ?: cwdLabel(session.workingDirectory.value) - // Clear the OSC 2 window title here too, so an app that set one stops naming the - // window once it exits. Falls back to the tab's own title while empty - see - // resolveWindowTitle in TabbedTerminal. - // - // Prompt start, NOT command start. Both were tried. The deciding fact is that our - // shell integration is sourced from .zshenv, so its hooks are registered before - // anything .zshrc adds and therefore run FIRST: a shell that sets its own OSC 2 from - // precmd (oh-my-zsh does) emits it just AFTER this clear, so its title survives and - // the window is untitled only for the instant in between. Clearing at 133;B instead - // leaves the title empty for the whole DURATION of every command on shells that set - // no per-command title - which also fed "BossTerm" to the completion notification, - // whose whole job is saying which session finished. - // - // The residual hazard is symmetric and unavoidable: a user who registers the snippet - // from .zshrc AFTER oh-my-zsh gets the reverse order, and then this clear discards a - // title precmd had just set. It degrades to the tab title rather than to a blank, and - // no hook is order-safe in general. - // - // Only fires where OSC 133 reaches: the bundled integration returns early inside - // tmux/screen and for TERM=dumb, and plenty of sessions have no integration at all. - // There a title still outlives the program that set it, exactly as it does today. + // Clear the OSC 2 window title too, so a program that set one stops naming + // the window after it exits; resolveWindowTitle then falls back to this same + // tab title. See AGENTS.md, "OSC 1 names the TAB, OSC 2 names the WINDOW" for + // why the split is kept and why the reset lives on THIS hook rather than on + // command start. Short version: our integration is sourced from .zshenv so its + // hooks run first, and a shell's own precmd OSC 2 lands just after this and + // survives; clearing at command start instead left the slot empty for the whole + // duration of every command, which fed "BossTerm" to the completion notification. // - // The display, not terminal.setWindowTitle: this is internal bookkeeping, and going - // through the terminal would publish it to every application-title listener as though - // the program had set an empty title. That includes EmbeddableTerminal's public - // onTitleChange, which only survives it by way of an isNotEmpty() guard. It also - // does not trigger the XTWINOPS title stack. (It does not hide from it either - - // saveWindowTitleOnStack reads this same field, so an app pushing a title around - // itself right after a prompt pushes "", as it already did on any shell that never - // emitted OSC 2.) + // display, not terminal.setWindowTitle: that publishes to every application-title + // listener as though the program had set an empty title, including + // EmbeddableTerminal's public onTitleChange. session.display.windowTitle = "" } } @@ -555,11 +537,11 @@ class TabController( // Register command state listener for notifications (OSC 133 shell integration). // Also captured in `tab.commandStateListeners` after construction so dispose() // can remove it (see TerminalTab.commandStateListeners docs). - val notificationTitle = NotificationTitleProvider(display, "BossTerm") + val notificationTitleProvider = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = notificationTitle, + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -646,7 +628,7 @@ class TabController( // them when the tab closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) - notificationTitle.attach(tab) + notificationTitleProvider.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -942,11 +924,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) - val notificationTitle = NotificationTitleProvider(display, sessionTitle) + val notificationTitleProvider = NotificationTitleProvider(display, sessionTitle) val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = notificationTitle, + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -1032,7 +1014,7 @@ class TabController( // pane closes. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(session) terminal.addCommandStateListener(lastCommandTracker) - notificationTitle.attach(session) + notificationTitleProvider.attach(session) session.commandStateListeners.add(notificationHandler) session.commandStateListeners.add(lastCommandTracker) @@ -1190,11 +1172,11 @@ class TabController( }) // Register command state listener for notifications (OSC 133 shell integration) - val notificationTitle = NotificationTitleProvider(display, "BossTerm") + val notificationTitleProvider = NotificationTitleProvider(display, "BossTerm") val notificationHandler = CommandNotificationHandler( settings = settings, isWindowFocused = isWindowFocused, - tabTitle = notificationTitle, + tabTitle = notificationTitleProvider, ) terminal.addCommandStateListener(notificationHandler) @@ -1250,7 +1232,7 @@ class TabController( // are recorded on the tab so dispose() can remove them. val lastCommandTracker = ai.rever.bossterm.compose.mcp.LastCommandTracker(tab) terminal.addCommandStateListener(lastCommandTracker) - notificationTitle.attach(tab) + notificationTitleProvider.attach(tab) tab.commandStateListeners.add(notificationHandler) tab.commandStateListeners.add(lastCommandTracker) @@ -2188,7 +2170,7 @@ class TabController( * between the two, so the reader is not guaranteed to see the assignment at all. [AtomicReference] * gives that edge for nothing. */ -private class NotificationTitleProvider( +internal class NotificationTitleProvider( private val display: ComposeTerminalDisplay, private val fallback: String, ) : () -> String { diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index 8f901d91b..a954dd97d 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -65,7 +65,7 @@ internal fun applyFullWindowContent( if (rootPane == null) return false if (!isMacOS) return false - return runCatching { + return try { rootPane.putClientProperty("apple.awt.fullWindowContent", true) // Caveat, and the one real cost of the transparent strip: AppKit draws the title text in // the colour its EFFECTIVE APPEARANCE dictates, not one picked to contrast with whatever @@ -86,11 +86,11 @@ internal fun applyFullWindowContent( // - hiding it would just lose the window name for no reason. rootPane.putClientProperty("apple.awt.windowTitleVisible", true) true - }.getOrElse { + } catch (e: Exception) { // putClientProperty on a JRootPane essentially cannot throw, so reaching here means // something is badly wrong - exactly when a silent un-inset window would be the worst // outcome to debug. - System.err.println("NativeTitleBarStyle: could not apply full window content: $it") + System.err.println("NativeTitleBarStyle: could not apply full window content: $e") false } } diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt index 5e1d10610..353860447 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/WindowTitleResolutionTest.kt @@ -1,5 +1,6 @@ package ai.rever.bossterm.compose +import ai.rever.bossterm.compose.tabs.NotificationTitleProvider import kotlin.test.Test import kotlin.test.assertEquals @@ -33,7 +34,7 @@ class WindowTitleResolutionTest { @Test fun `an empty OSC 2 title falls back to the tab title`() { - // Empty is the RESET written at each command start, not a real title - without the + // Empty is the RESET written at each prompt start, not a real title - without the // fallback an exited program would leave the window nameless. assertEquals("src", resolveWindowTitle(custom = null, osc2 = "", tabTitle = "src")) } @@ -91,4 +92,21 @@ class WindowTitleResolutionTest { notificationTitle(custom = null, osc2 = "", tabTitle = "", fallback = "BossTerm"), ) } + + @Test + fun `a notification before its tab is attached falls back rather than crashing`() { + // NotificationTitleProvider is built before the TerminalTab exists and has the tab dropped + // in afterwards. Nothing should invoke it in between, but if anything ever does, the + // answer has to be the old behaviour rather than an exception on a background thread. + val display = ComposeTerminalDisplay() + display.windowTitle = "me@host: ~/src" + val provider = NotificationTitleProvider(display, fallback = "BossTerm") + + // The app's own OSC 2 title still comes through with no tab attached... + assertEquals("me@host: ~/src", provider()) + + // ...and with nothing at all, the fallback, which is exactly pre-PR behaviour. + display.windowTitle = "" + assertEquals("BossTerm", provider()) + } } From cf0ba4ddc369633d9d13f5395346ab1dd2610a14 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 9 Aug 2026 12:07:37 -0700 Subject: [PATCH 12/12] Derive the window appearance from our background, not the system's Confirmed by hand rather than predicted: with a light system appearance and the default dark terminal background, the window title was drawn near-black on near-black and was unreadable. transparentTitleBar puts the title text over our background, but AppKit still picks that text's colour from the window appearance, so following the system guarantees the mismatch whenever the two disagree. nativeTitleBarAppearance derives apple.awt.application.appearance from the terminal background using Rec. 709 luma, and main() applies it before AWT boots because the property is read once at initialisation - early enough to beat the deep-link handler, which touches java.awt.Desktop. Fixes both directions: a light background on a dark system had the mirror problem. A supported system property rather than JNA. There is no per-window appearance client property, and the ObjC bridge in window/WindowTransparency.kt is not the alternative it appears to be: its only consumer is commented out there as having "compatibility issues with modern macOS/Java". Two deliberate limits. It is app-wide, so the native context menus follow the terminal background rather than the system - for a terminal that is the more consistent answer, but it is a visible change. And it applies only on the native title bar path, since a custom title bar has no system-drawn title to keep legible. Unparseable colours are treated as dark, because guessing light is the direction that produces unreadable text. --- AGENTS.md | 12 ++-- .../kotlin/ai/rever/bossterm/app/Main.kt | 12 ++++ .../compose/window/NativeTitleBarStyle.kt | 65 +++++++++++++++---- .../compose/window/NativeTitleBarStyleTest.kt | 46 +++++++++++++ 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5f168fa0..cc463b92e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,10 +95,14 @@ macOS itself allows it, which is how Terminal.app is transparent with traffic li window that cannot be. So transparency belongs to the undecorated path only, which is exactly what `useNativeTitleBar = false` selects. See `compose-ui/.../window/NativeTitleBarStyle.kt`. -One consequence rides along: with `apple.awt.transparentTitleBar` AppKit draws the title text in the -colour the window's effective appearance dictates, not one picked to contrast with what shows -through - so a light system appearance draws a dark title over the dark terminal background -(issue #368). +One consequence rides along, and it is why the app no longer follows the system appearance: with +`apple.awt.transparentTitleBar` the title text sits over OUR background, but AppKit still picks that +text's colour from the window appearance. Following the system therefore guarantees an unreadable +title whenever the two disagree - confirmed by hand, a light system appearance drew a near-black +title on the near-black default background. `nativeTitleBarAppearance` derives +`apple.awt.application.appearance` from the terminal background instead, which fixes both +directions. It has to be applied before AWT boots, and it is app-wide, so other AWT chrome (the +native context menus) follows the terminal background too. ### OSC 1 names the TAB, OSC 2 names the WINDOW diff --git a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt index 4faacb4b2..c4bbf203f 100644 --- a/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt +++ b/bossterm-app/src/desktopMain/kotlin/ai/rever/bossterm/app/Main.kt @@ -35,6 +35,7 @@ import ai.rever.bossterm.compose.update.UpdateBanner import ai.rever.bossterm.compose.update.UpdateManager import ai.rever.bossterm.compose.window.CustomTitleBar import ai.rever.bossterm.compose.window.applyFullWindowContent +import ai.rever.bossterm.compose.window.nativeTitleBarAppearance import ai.rever.bossterm.compose.window.titleBarInset import ai.rever.bossterm.compose.window.GlobalHotKeyManager import ai.rever.bossterm.compose.window.HotKeyConfig @@ -79,6 +80,17 @@ fun main(args: Array) { return } + // Window appearance, derived from OUR background rather than the system's. Must be here: + // apple.awt.application.appearance is read once when AWT initializes, so this has to beat the + // deep-link handler below (it touches java.awt.Desktop) as well as any window. See + // nativeTitleBarAppearance for why following the system is not an option once the title bar is + // transparent. Settings are plain file + JSON at this point, no toolkit involved. + SettingsManager.instance.settings.value.let { s -> + nativeTitleBarAppearance(s.useNativeTitleBar, s.defaultBackground)?.let { + System.setProperty("apple.awt.application.appearance", it) + } + } + // Configure GPU rendering (must be before any Skiko/Compose initialization) configureGpuRendering() diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt index a954dd97d..b3855ddb8 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyle.kt @@ -67,19 +67,12 @@ internal fun applyFullWindowContent( return try { rootPane.putClientProperty("apple.awt.fullWindowContent", true) - // Caveat, and the one real cost of the transparent strip: AppKit draws the title text in - // the colour its EFFECTIVE APPEARANCE dictates, not one picked to contrast with whatever - // shows through. The packaged app runs with -Dapple.awt.application.appearance=system - // (bossterm-app/build.gradle.kts), so in macOS Light Mode the title is drawn near-black - // over the terminal background - which is dark by default. There is no supported per-window - // appearance client property (CPlatformWindow honours only the three set here, plus - // fullscreenable and some fade/shadow keys), so fixing it means either forcing the NSWindow - // appearance through JNA or hiding the system title and drawing it in the reserved strip. - // Left as-is deliberately: this is the native title bar, and the native title is part of it. - // Tracked in issue #368. The ObjC bridge to build the first option on is in this package, - // in window/WindowTransparency.kt - but note before assuming it is turnkey that its only - // consumer (configureMacOSBlur) is commented out there as having "compatibility issues - // with modern macOS/Java", so the binding is unproven rather than merely unused. + // This is what makes the title's colour our problem: AppKit then draws the title text in + // the colour the window's EFFECTIVE APPEARANCE dictates, not one picked to contrast with + // whatever shows through. See [nativeTitleBarAppearance], which is why the app no longer + // simply follows the system here. Confirmed by hand: with the stock + // -Dapple.awt.application.appearance=system, a light system appearance drew a near-black + // title over the dark terminal background and the window name was unreadable. rootPane.putClientProperty("apple.awt.transparentTitleBar", true) // The title stays visible. It is drawn centred in the title bar strip, and callers reserve // exactly that strip with NATIVE_TITLE_BAR_HEIGHT, so there is nothing for it to overlap @@ -121,3 +114,49 @@ internal fun applyFullWindowContent( */ fun titleBarInset(styleApplied: Boolean, placement: WindowPlacement): Dp = if (styleApplied && placement != WindowPlacement.Fullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp + +/** + * Whether a background colour is dark enough that light text belongs on it. + * + * @param argbHex the stored `defaultBackground`, an `0xAARRGGBB` string. + * @return true for anything unparseable, because the shipped default is dark and a wrong guess + * towards light is the one that produces unreadable text. + */ +internal fun isDarkBackground(argbHex: String): Boolean { + val value = argbHex.removePrefix("0x").removePrefix("0X").toULongOrNull(16) ?: return true + val red = ((value shr 16) and 0xFFu).toDouble() + val green = ((value shr 8) and 0xFFu).toDouble() + val blue = (value and 0xFFu).toDouble() + // Rec. 709 luma. The channels are weighted because the eye is far more sensitive to green + // than to blue, so a plain average calls colours like deep blue "light" when they read black. + return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 128.0 +} + +/** + * The value for `apple.awt.application.appearance`, or null to leave it alone. + * + * The native title bar makes this necessary. `transparentTitleBar` stops the system painting its + * own strip, so the title text ends up over OUR background, but AppKit still picks that text's + * colour from the window appearance. Following the SYSTEM appearance therefore guarantees an + * unreadable title whenever the two disagree, which was confirmed by hand: light system appearance + * plus the default dark terminal background gave a near-black title on near-black. + * + * Deriving it from the terminal background instead fixes both directions at once, and does it + * through a supported system property rather than JNA. There is no per-window appearance client + * property to reach for - `CPlatformWindow` honours only `fullWindowContent`, + * `transparentTitleBar`, `windowTitleVisible`, `fullscreenable` and some fade/shadow keys - and the + * ObjC bridge in `window/WindowTransparency.kt` is not the alternative it looks like: its only + * consumer is commented out there as having "compatibility issues with modern macOS/Java". + * + * The cost is that this is app-wide, so other AWT chrome (notably the native context menus) follows + * the terminal background rather than the system. For a terminal that is the more consistent + * answer, and it only applies on the native title bar path: with a custom title bar there is no + * system-drawn title to keep legible, so the system appearance is left alone. + * + * Must be applied BEFORE AWT boots - the property is read once at initialisation. + */ +fun nativeTitleBarAppearance(useNativeTitleBar: Boolean, backgroundHex: String): String? { + if (!ShellCustomizationUtils.isMacOS()) return null + if (!useNativeTitleBar) return null + return if (isDarkBackground(backgroundHex)) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt index 14ac37988..2ab1b8241 100644 --- a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/window/NativeTitleBarStyleTest.kt @@ -1,5 +1,6 @@ package ai.rever.bossterm.compose.window +import ai.rever.bossterm.compose.shell.ShellCustomizationUtils import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement import javax.swing.JRootPane @@ -80,4 +81,49 @@ class NativeTitleBarStyleTest { assertEquals(0.dp, titleBarInset(false, placement), "unstyled $placement must not inset") } } + + // ---- the appearance the title text is drawn from ---- + + @Test + fun `the shipped default background is dark`() { + // 0xFF05070B. If this ever reads light, every window ships an unreadable title. + assertTrue(isDarkBackground("0xFF05070B")) + } + + @Test + fun `luminance is weighted, not averaged`() { + // A plain channel average calls saturated blue "light" at 0,0,255 (avg 85 vs the 128 + // threshold is dark, but 0,0,255 against a naive max/mid test is not), while green at the + // same value is genuinely light. Weighting is what separates them. + assertTrue(isDarkBackground("0xFF0000FF"), "saturated blue reads as dark") + assertFalse(isDarkBackground("0xFF00FF00"), "saturated green reads as light") + } + + @Test + fun `an unparseable background is treated as dark`() { + // Guessing "light" would put dark text on what is probably a dark background. The shipped + // default is dark, so this is the safe direction. + assertTrue(isDarkBackground("not a colour")) + assertTrue(isDarkBackground("")) + } + + @Test + fun `appearance follows the background, not the system`() { + if (!ShellCustomizationUtils.isMacOS()) return + assertEquals( + "NSAppearanceNameDarkAqua", + nativeTitleBarAppearance(useNativeTitleBar = true, backgroundHex = "0xFF05070B"), + ) + assertEquals( + "NSAppearanceNameAqua", + nativeTitleBarAppearance(useNativeTitleBar = true, backgroundHex = "0xFFFFFFFF"), + ) + } + + @Test + fun `the custom title bar leaves the system appearance alone`() { + // Nothing system-drawn to keep legible there, so forcing the whole app's chrome would be + // an unrelated change the user did not ask for. + assertNull(nativeTitleBarAppearance(useNativeTitleBar = false, backgroundHex = "0xFF05070B")) + } }