Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,41 @@ 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, 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

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.

Expand Down Expand Up @@ -111,6 +146,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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,6 +34,9 @@ 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.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
import ai.rever.bossterm.compose.window.WindowManager
Expand Down Expand Up @@ -75,6 +80,17 @@ fun main(args: Array<String>) {
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()

Expand Down Expand Up @@ -284,6 +300,30 @@ fun main(args: Array<String>) {
}
}
) {
// 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.
// 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 && ShellCustomizationUtils.isMacOS() }
// 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) {
styleApplied = applyFullWindowContent(this@Window.window)
}
}

// Update manager state
val updateManager = remember { UpdateManager.instance }
val updateState by updateManager.updateState.collectAsState()
Expand Down Expand Up @@ -729,6 +769,11 @@ fun main(args: Array<String>) {
windowState.placement == WindowPlacement.Maximized
val cornerRadius = if (useNativeTitleBar || isFullscreenOrMaximized) 0.dp else 20.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 topInset = titleBarInset(styleApplied, windowState.placement)

// Load background image if set
val backgroundImage = remember(windowSettings.backgroundImagePath) {
if (windowSettings.backgroundImagePath.isNotEmpty()) {
Expand Down Expand Up @@ -810,6 +855,13 @@ fun main(args: Array<String>) {
}

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. Zero in fullscreen; see
// titleBarInset.
Spacer(modifier = Modifier.height(topInset))

// Custom title bar (only when not using native title bar)
if (!useNativeTitleBar) {
CustomTitleBar(
Expand Down Expand Up @@ -905,10 +957,14 @@ fun main(args: Array<String>) {
// 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
// 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 = 8.dp, end = 12.dp)
.padding(top = topInset + 8.dp, end = 12.dp)
) {
Text(
text = globalHotkeyHint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ class ComposeTerminalDisplay : TerminalDisplay {
val bellTrigger: State<Int> = _bellTrigger
val progressState: State<TerminalDisplay.ProgressState> = _progressState
val progressValue: State<Int> = _progressValue
/**
* The app's OSC 2 window title.
*
* 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.
*
* 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<String> = _windowTitle.asStateFlow()
val iconTitleFlow: StateFlow<String> = _iconTitle.asStateFlow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1626,16 +1626,40 @@ 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.
//
// 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 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. TabController clears it
// 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
focused.display.windowTitleFlow.collect { newTitle ->
if (newTitle.isNotEmpty()) {
onWindowTitleChange(newTitle)
snapshotFlow { focused.customTitle.value to focused.title.value }
.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)
}
}
}
}

// Daemon-mirrored pane: route split/close through the daemon instead of touching the
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `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
* afternoon rediscovering it.
*/
val useNativeTitleBar: Boolean = true,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading