Skip to content

feat(window): unified native title bar, and a window title that is actually dynamic - #367

Merged
kshivang merged 12 commits into
masterfrom
feat/full-window-content
Aug 9, 2026
Merged

feat(window): unified native title bar, and a window title that is actually dynamic#367
kshivang merged 12 commits into
masterfrom
feat/full-window-content

Conversation

@kshivang

@kshivang kshivang commented Aug 9, 2026

Copy link
Copy Markdown
Owner

What

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 client properties to fix that -
apple.awt.fullWindowContent lets the content pane extend under the title bar and
apple.awt.transparentTitleBar stops the system painting there - so the terminal background now
runs edge to edge with the traffic lights sitting directly on it, and the title centred in that
strip. It is the look every modern terminal has.

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" trick reports zero.

Second commit fixes a bug found while looking at the result: the window title was static. It was
fed from display.windowTitleFlow, the OSC 2 window title, which most shells never emit - 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.

What this is not

Transparency. Those are separate things and only this one is available with a native title bar. The
KDoc on NativeTitleBarStyle records why, because the existing note in TerminalSettings reads as
a platform limitation and it is not one:

  • 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, on
    setBackground(alpha<255) and setOpacity, before and after the window is shown.
  • Forcing the NSWindow non-opaque underneath is not enough. Measured through the peer:
    CPlatformWindow.setOpaque(false) runs, peer.isTextured() is false so it does clear the
    background, and the window reports isOpaque = NO - and alpha still composites onto black,
    because the surface has no alpha channel.
  • Confirmed by the controlled case: the same alpha-0 fill in an undecorated window is
    see-through, in a decorated one it is solid black.
  • macOS itself allows it, which is how Terminal.app and iTerm2 are transparent with traffic lights.
    It is AWT's window that cannot be, so transparency stays what it already is here: the undecorated
    path's, which is exactly what useNativeTitleBar = false selects.

Scope

macOS only. The client properties are the supported JDK route there and are ignored elsewhere;
gated on isMacOS() anyway so the intent is obvious, and every call is best-effort - a failure
returns false and the caller simply does not inset, leaving today's behaviour.

No new setting. This is how the existing native title bar mode looks; the custom title bar path is
untouched and still owns transparency, blur and rounded corners.

Fullscreen

The inset is not unconditional: it is gated on
windowState.placement != WindowPlacement.Fullscreen, because macOS hides the title bar in
fullscreen and reserving the strip there would leave a dead band of background above the tabs.

Deliberately Fullscreen only, never Maximized. The window already computes
isFullscreenOrMaximized nearby, but that is the wrong predicate here: macOS zoom keeps the title
bar, so gating on it would put the tab bar back under the traffic lights whenever the window is
zoomed.

Window title

The window title used to read display.windowTitleFlow alone, so it sat on its startup name for
every shell that never emits OSC 2 while the tab bar beside it tracked the directory. It now
resolves a precedence chain (resolveWindowTitle): a Rename… custom title, then the app's OSC 2
window title, then the tab's own title.

The tab label is deliberately left on OSC 1. Folding both OSC titles into session.title was tried
and reverted: that field is also the tab label, and xterm's split - which this codebase already
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 from precmd, so merging them made the tab label
depend on which flow happened to be scheduled last.

The OSC 2 title is cleared at prompt start so a program that set one stops naming the window after
it exits. Command start (133;B) was tried and reverted: it leaves the 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.

Prompt start is safe because the bundled integration is sourced from .zshenv, so its hooks are
registered ahead of anything .zshrc adds and run first: a shell that sets its own OSC 2 from
precmd (oh-my-zsh does) emits it just after the clear, and the window is untitled only for the
instant in between. A user who wires the snippet up from .zshrc after oh-my-zsh gets the reverse
order and loses that title to the clear; the hazard is symmetric and no hook is order-safe in
general, which is written down at the call site rather than argued either way.

While in here, completion 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 instead of
every tab announcing "BossTerm".

The clear needs OSC 133, so it does not happen inside tmux/screen or without the shell integration;
there a title outlives its program exactly as it does today.

Verification

./gradlew build green; 1041 compose-ui tests pass. New coverage is on the two things that
actually changed: the applyFullWindowContent contract and the window title precedence. The former
goes through a JRootPane overload with the platform injected, because no java.awt.Window can be
constructed in a headless JVM (the constructor throws HeadlessException) and CI is headless on
every runner - so both the applied and the declined branch are asserted everywhere rather than each
runner skipping half.

Exercised by hand in the running app: traffic lights native and unchanged, title centred and
following cd, tab bar clear of the lights.

WindowState.placement was traced rather than assumed, since the whole gate rests on it. Compose
syncs it from 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 - the real NSWindow state, not a flag set only when
we request fullscreen. So a green-button fullscreen is covered. A bounds-vs-screen heuristic was
tried in place of it and removed: it cannot tell fullscreen from a zoomed window once the menu bar
and Dock auto-hide.

The title's colour became our problem, so the appearance is now derived rather than followed.
transparentTitleBar puts the title text over BossTerm's own 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, and it does: 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
(Rec. 709 luma) and main() applies it before AWT boots, since the property is read once at
initialisation. That fixes both directions - a light background on a dark system had the mirror
problem - through 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
looks like: 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, which for a terminal reads as the more consistent answer; and it applies
only on the native title bar path, since a custom title bar has no system-drawn title to keep
legible.

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.
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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Nice change, and the PR description is unusually good — the transparency section in particular saves the next person from re-running that experiment. I traced the title-source change through TabController.wireCwdTitle and it holds up: split panes get the same wiring (TabController.kt:1014, inside createSessionForSplit), so the focused-pane title stays live after a split, and OSC 0/1 still reaches the window title via iconTitleFlow — only an OSC-2-only title is dropped, which you name as intentional. No correctness problems found in the title commit.

Findings on the first commit, roughly by importance.

1. Fullscreen inset — confirmed, and the tempting fix is the wrong one

Main.kt:828 reserves the strip unconditionally, so in macOS fullscreen (title bar gone) you get a 28dp dead band of background at the top. You flagged this; it is real.

The one-line fix is close, but note isFullscreenOrMaximized at Main.kt:742 is not the right predicate — macOS zoom (Maximized) keeps the title bar, so gating on it would re-introduce the overlap in the maximized case. It has to be Fullscreen only:

if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) {
    Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT_DP.dp))
}

Worth actually verifying that windowState.placement tracks a green-button fullscreen and not just a programmatic placement = Fullscreen — AWT's window-state events on macOS are the flaky part here. If it doesn't track, there is already ObjC plumbing you could lean on (getNSWindow in window/WindowTransparency.kt) to read the styleMask instead.

2. Side effect inside remember (Main.kt:296-299)

remember {} blocks are expected to be pure — Compose may run a composition it later discards. It is harmless here (idempotent client-property writes, stable window object), but I suspect you chose remember over SideEffect deliberately, because fullWindowContent wants to be set before the window is shown and the content composes first. If so, that is the interesting part and it belongs in the comment — as written it reads like an accident. Also remember(useNativeTitleBar) keys on a value that is itself remember {}-captured at Main.kt:265, so it is effectively remember(Unit); harmless, slightly misleading.

3. applyFullWindowContent: the when can just be RootPaneContainer

val rootPane = (window as? javax.swing.RootPaneContainer)?.rootPane ?: return false

Same behaviour, covers JWindow too, and it is the interface the JDK itself uses for exactly this.

4. Silent failure

runCatching { … }.getOrDefault(false) swallows everything including Error. putClientProperty on a JRootPane essentially cannot throw, so in practice this only fires if something is badly wrong — which is precisely when you would want a line in the log rather than a silently un-inset window. A debug log in the failure branch would cost nothing.

5. Make the constant a Dp

NATIVE_TITLE_BAR_HEIGHT_DP: Int = 28 puts the unit in the name, which is the job Dp already does, and leaves every call site to remember .dp (or not). val NATIVE_TITLE_BAR_HEIGHT: Dp = 28.dp is type-safe and makes it impossible to use as a pixel count by accident. The module already depends on Compose.

For what it is worth, 28pt checks out for a regular-size window, and there is no custom LocalDensity anywhere in the app, so 28.dp does land on 28pt on both Retina and non-Retina.

6. The doc you set out to fix is still there

The PR says the note in TerminalSettings "reads as a platform limitation and it is not one" — but TerminalSettings.kt:169 still says "Native macOS title bar, proper fullscreen, but no transparency" with no explanation, and the user-facing copy at VisualSettingsSection.kt:148 and :161 says the same. The new KDoc is excellent but lives in a file nobody reads on their way to that setting. One See [NativeTitleBarStyle] on the useNativeTitleBar KDoc closes the loop.

7. Tests

Nothing added. The title change is genuinely awkward to test without Compose UI test infra, so I would not push on that one — but applyFullWindowContent's contract (false off-macOS, false for a non-RootPaneContainer window) is a pure function of its input, and there is a compose-ui/src/desktopTest tree to put it in. If you take the RootPaneContainer refactor above, a headless test of that second branch is a few lines and needs no display.

Minor / optional

  • Reserving the full-width strip means the tab bar can never sit beside the traffic lights, which is what iTerm2 and Ghostty actually do. Insetting the tab bar's start by the traffic-light width (~78pt) instead of pushing everything down would get closer to the look the description is after. Separate change, not a blocker.
  • Fullscreen aside, this does not apply to secondary windows (settings, etc.), which keep the old two-piece look. Probably fine — just noting the asymmetry.

Nothing here is blocking except the fullscreen inset, and you had already found that one.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review (second pass — commit 65364ee)

The follow-up commit picked up essentially all of the earlier feedback: Dp constant instead of …_HEIGHT_DP: Int, RootPaneContainer instead of the when, a log line in the failure branch, the remember-vs-SideEffect rationale written down, the TerminalSettings KDoc now pointing at the measured explanation, and a headless test for the part of the contract CI can actually execute. The fullscreen gate landed too, and gating on Fullscreen only (never Maximized) is the right call — macOS zoom keeps the title bar.

One new finding from re-reading the whole window tree, then smaller things.

1. The global-hotkey hint now renders inside the title bar — Main.kt:937

The inset is applied to the Column, but the hotkey hint is a sibling of that Column, aligned to the outer Box:

if (useNativeTitleBar && globalHotkeyHint != null) {
    Box(modifier = Modifier.align(Alignment.TopEnd).padding(top = 8.dp, end = 12.dp)) { … }
}

That Box fills the Surface, which now starts at the frame top rather than below the title bar — so the hint moves up 28pt and lands in the title bar strip, on the right, opposite the traffic lights. And the gate is useNativeTitleBar, i.e. exactly the path this PR changes; the custom title bar renders the hint inside CustomTitleBar instead, so it is unaffected.

Nothing breaks functionally (the text is display-only and clicks still reach the native title bar), but on macOS the window title is centred, so with a longer title (~/some/deep/directory) the two will crowd or collide. Repro: globalHotkeyEnabled + showGlobalHotkeyHint on, window 1–9, native title bar.

If it should stay below the strip:

.padding(top = if (fullWindowContent) NATIVE_TITLE_BAR_HEIGHT + 8.dp else 8.dp, end = 12.dp)

(If you'd rather it sit in the title bar deliberately — it's a reasonable place for it — that's fine, but then it wants to be end-padded clear of the centred title, and the choice belongs in a comment, because right now it reads as a miss.)

2. The fullscreen gate is right; whether placement sees green-button fullscreen still isn't verified

Main.kt:839 keys off windowState.placement, which is correct if Compose propagates a fullscreen entered from the green traffic light back into WindowState — AWT's macOS window-state events are the flaky part, and nothing else in the app depends on that value on the native path (Main.kt:747 only matters when useNativeTitleBar is false), so it has never been exercised. If it doesn't propagate you get the 28dp dead band the gate was written to prevent. One manual check settles it; if it turns out not to track, getNSWindow in window/WindowTransparency.kt already gives you the styleMask route.

3. OSC 2 is now inert app-wide, and the two embed surfaces disagree

TabbedTerminal.kt:1642 was the last consumer of windowTitleFlow in the app — nothing else reads it (ProperTerminal doesn't; the remaining hits are EmbeddableTerminal, the daemon/share path, and TerminalSessionCore). So after this, an OSC 2 title has no observable effect anywhere in BossTerm.

The reasoning in the comment is sound for the shell case, and OSC 0 is safe because BossEmulator1.doProcessOsc sets both titles and TabController.wireCwdTitle mirrors the icon title. But OSC 2 is not only "the thing shells don't emit" — it's also what several TUIs use for their title (vim's t_ts for xterm, and any printf '\033]2;…\a'), and those now silently do nothing. EmbeddableTerminal.kt:316 still collects windowTitleFlow, so an embedding host and the app now derive the window title from different sources.

Cheapest fix that keeps everything the comment argues for: mirror windowTitleFlow into session.title right next to the iconTitleFlow collector at TabController.kt:158-167, with the same customTitle-wins and prompt-reset semantics. Then both surfaces agree, the tab bar and the window title still agree, and an OSC-2-only app gets its name back and reverts on exit. If dropping OSC 2 is genuinely intended, worth saying so explicitly in the comment (it currently reads as "OSC 2 is unreliable", not "OSC 2 is discarded") and aligning EmbeddableTerminal.

4. Tests: the macOS branch never runs, and the null case passes for the wrong reason off macOS

Both platform tests return early, so on a Linux runner you assert two things, and on a macOS runner the other two — neither machine checks the whole contract. Worse, applyFullWindowContent checks isMacOS() before the null check, so a window with no root pane is declined rather than half-styled passes on Linux without ever reaching the branch it names.

Two small changes make all four assertions run everywhere:

  • move if (rootPane == null) return false above the platform check — the null case is a decline on every platform, and the test then tests what it says;
  • make the platform a parameter, e.g. internal fun applyFullWindowContent(rootPane: JRootPane?, isMacOS: Boolean = ShellCustomizationUtils.isMacOS()), so both the applied and the skipped branch are asserted on any runner. The public Window overload keeps its current signature.

Minor

  • NativeTitleBarStyle.kt:64 still says NATIVE_TITLE_BAR_HEIGHT_DP — leftover from the pre-Dp iteration.
  • The PR description says the inset is unconditional and fullscreen is unhandled; commit 3 handled it. Worth updating before merge so the description matches what reviewers and git log will read.
  • Perf, low: the window title now changes on every cd, and title = window.title.value is read at Main.kt:275, inside the per-window loop scope — so each change invalidates the scope wrapping the whole Window. snapshotFlow dedupes, so it is only real changes, and inner composables should skip on unchanged params; just something to keep in mind if a title-thrashing TUI ever shows up in a recomposition profile.
  • UX, subjective: every window is now titled ~/dirname, so the macOS Window menu and Mission Control no longer show "BossTerm" anywhere. iTerm2 defaults the same way, so this is probably what you want — noting it in case the app-name-in-Mission-Control loss is unintended.

Nothing here blocks except item 1, which is a one-line fix.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Nice change, and the PR description does a lot of the reviewer's work for it — the "what this is not" section on AWT transparency is the kind of thing that saves the next person an afternoon, and putting it in the KDoc next to the code rather than only in the PR body is the right call. The applyFullWindowContent seam (public Window overload + internal JRootPane overload with the platform injected) is a clean way to get a headless-testable contract out of an AppKit-only feature.

A few things below, roughly in severity order.


1. The hotkey hint keeps its 28dp inset in fullscreen — the two inset sites disagree

Main.kt:838 gates the spacer on placement:

if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) {
    Spacer(modifier = Modifier.height(NATIVE_TITLE_BAR_HEIGHT))
}

but Main.kt:944 gates the hint's padding only on fullWindowContent:

top = if (fullWindowContent) NATIVE_TITLE_BAR_HEIGHT + 8.dp else 8.dp

In fullscreen the Column no longer reserves the strip, so content starts at y=0 — but the hint is still pushed down 36dp, which now lands it inside the tab bar row instead of above it. Same predicate, two copies, one of them missing a term. Worth hoisting a single value next to isFullscreenOrMaximized (line 747) and using it at both sites:

val titleBarInset =
    if (fullWindowContent && windowState.placement != WindowPlacement.Fullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp

Then the spacer is Spacer(Modifier.height(titleBarInset)) (or keep the if) and the hint is top = titleBarInset + 8.dp. That also makes the Fullscreen-not-Maximized reasoning live in exactly one place, which is where the comment explaining it belongs.

2. Merging OSC 2 into session.title changes the tab label, not just the window title

TabController.kt:170:

merge(session.display.iconTitleFlow, session.display.windowTitleFlow).collect { … session.title.value = newTitle }

The goal — an OSC-2-only TUI should reach the window title — is right, but session.title is also the tab label, and the two OSC titles are deliberately different strings when an app sets both. BossEmulator1.doProcessOsc keeps them separate (OSC 0 → both, OSC 1 → icon, OSC 2 → window), and ProperTerminal.kt:892 documents the split the other way round: "Icon title (OSC 1) is used for tab labels… Window title (OSC 2) is used for the main window title bar."

oh-my-zsh's title helper is the concrete case: ZSH_THEME_TERM_TAB_TITLE_IDLE goes out as OSC 1 (%~, short) and ZSH_THEME_TERM_TITLE_IDLE as OSC 2 (%n@%m: %~, long), one immediately after the other in precmd. With merge, last write wins and there is no ordering guarantee between two concurrently-collected flows — so the tab label becomes either the short or the long form depending on scheduling, and during a command (preexec) it'll show user@host: vim foo where it used to show vim foo. On a shell with OSC 133 wired up the prompt-reset listener papers over the idle case, which makes it worse to diagnose: it only misbehaves while a command is running.

Suggestion: keep session.title on OSC 1 as it was, and resolve the window title with a precedence chain at the consumer in TabbedTerminal instead — customTitle ?: osc2.ifEmpty { tabTitle }. Something like:

snapshotFlow { focused.customTitle.value to focused.title.value }
    .combine(focused.display.windowTitleFlow) { (custom, tabTitle), osc2 ->
        custom ?: osc2.ifEmpty { tabTitle }
    }
    .collect { … }

That gets the OSC-2-only TUI onto the window title (the actual bug being fixed) without the tab bar inheriting a string that was never meant for it, and it keeps the xterm distinction the codebase already documents.

3. WindowState.placement and the green button — worth hardening, not just verifying

You've flagged this as the outstanding manual check, and I think the pessimistic answer is the likely one: Compose Desktop syncs placement back from a WindowStateListener, which fires on extendedState transitions (iconified/maximized). macOS native fullscreen isn't an extendedState change, so the listener plausibly never fires and placement stays Floating through a green-button fullscreen — leaving exactly the 28dp dead band the gate exists to avoid.

Cheaper than the getNSWindow styleMask route: ComposeWindow.placement's own getter does the macOS-aware fullscreen check, and componentResized does fire on the fullscreen transition. A small ComponentAdapter on this@Window.window that pushes window.placement into a mutableStateOf would make the gate independent of whatever Compose's state sync does. Fine to land as-is and follow up once you've checked it by hand — but if it turns out not to track, the fix is a listener rather than a peer-poking detour.

4. NATIVE_TITLE_BAR_HEIGHT could be measured once instead of assumed

The KDoc's justification is that after fullWindowContent the window.height - contentPane.height trick reports zero — true, but the frame is already realized when the remember block runs, so (window as? Frame)?.insets?.top read before the property writes gives the real value, with 28 as the fallback. applyFullWindowContent would then return the height rather than a boolean (Dp?, null = declined), which also collapses the caller's "did it apply" and "how much to inset" into one fact instead of two that can drift. Optional — 28pt is correct for a regular-size window today — but it survives a future where it isn't.

5. Smaller notes

  • EmbeddableTerminal.kt:316 still collects display.windowTitleFlow for onTitleChange, so the "title sits on a static string because nothing emits OSC 2" bug this PR fixes for the app remains for embedders — and the two paths now resolve titles differently. Out of scope for the diff, but worth a follow-up issue.
  • Side effect inside remember — the comment justifies it well (has to land before the first measure) and the write is idempotent, so no objection. One thing the comment doesn't say: it also relies on CPlatformWindow picking these client properties up after the window is displayable, which it does on modern JDKs via its root-pane PropertyChangeListener. Worth a half-line, since "must be set before the peer exists" is the folklore answer and someone will eventually "fix" this by moving it earlier.
  • DocsAGENTS.md's "Critical Technical Patterns" section is exactly where the next person would look before opening the window code. The KDoc is good; a two-line pointer there would be better.
  • println on the failure path matches the convention in this package (GlobalHotKeyManager, WindowVisibilityController), so no complaint there.

6. Test coverage

The four new tests cover the right contract for the part CI can see, and injecting the platform rather than skipping on non-macOS runners is the correct trade — both branches assert everywhere. Two observations:

  • the reserved strip is the standard macOS title bar height asserts 28.dp == 28.dp through a constant. It's a change-detector: it can only fail when someone deliberately edits the constant, at which point they'll edit the test too. If the height became a measurement with a fallback (point 4) there'd be something real to assert.
  • Nothing covers the riskier half of the PR. The client-property writes essentially cannot go wrong; the title precedence can. TabController.wireCwdTitle is unit-testable against a ComposeTerminalDisplay with no PTY — set iconTitle/windowTitle, assert what session.title lands on, assert customTitle still wins, assert the prompt reset reverts. That's where a regression will actually come from, and it would have surfaced point 2 as a design question rather than a runtime surprise.

Points 1 and 2 are the ones I'd want addressed before merge; 3 is your own open question with a suggested hardening; 4–6 are take-or-leave.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Nice change — the writeup and the KDoc on NativeTitleBarStyle are genuinely useful (recording the measured AWT alpha finding so nobody re-derives it is exactly the right instinct). The applyFullWindowContent seam with an injectable isMacOS, so a headless runner can execute both branches, is a good pattern too.

A few things I'd want addressed before merge, roughly in severity order.


1. The centred title will be dark-on-dark in macOS Light Mode

bossterm-app/build.gradle.kts:184 ships the packaged app with -Dapple.awt.application.appearance=system. With titlebarAppearsTransparent (which is what apple.awt.transparentTitleBar sets), AppKit draws the title text in the colour dictated by the window's effective appearance, not by what is behind it. So in Light Mode the title renders near-black — directly over BossTerm's default dark terminal background, which now runs under the strip. Previously the system painted a light strip behind it, and that is what kept it legible.

windowTitleVisible = true is a deliberate choice here, and the KDoc argues for it ("hiding it would just lose the window name for no reason") — but in Light Mode it loses the window name anyway, just less visibly. The manual pass was presumably done in Dark Mode; worth re-checking with System Settings → Appearance → Light.

Options, roughly in order of effort:

  • Derive the window appearance from windowSettings.defaultBackgroundColor's luminance and force it (NSWindow.setAppearance: via the getNSWindow route already in window/WindowTransparency.kt, or the apple.awt.windowAppearance client property if the bundled runtime honours it).
  • Or set windowTitleVisible = false and draw the title yourself in the reserved 28dp strip, which also gets you control over what it says (see item 5).

Either way it is worth a line in the KDoc, since it is the same class of "measured, don't rediscover" fact as the alpha one.

2. isNativeFullscreen misclassifies zoom when the menu bar auto-hides

internal fun isNativeFullscreen(windowBounds: Rectangle?, screenBounds: Rectangle?): Boolean =
    windowBounds == screenBounds

The KDoc says the discriminator is the menu bar: "Fullscreen covers the WHOLE display; zoom only fills the visible frame, leaving the menu bar and the Dock." That holds only for the default configuration. With "Automatically hide and show the menu bar" (and an auto-hiding Dock) the visible frame is the display, so a zoomed window's bounds equal the screen bounds exactly and boundsSayFullscreen goes true. A user who manually drags a window to fill the display hits the same thing.

The consequence is precisely the failure the inset exists to prevent: titleBarInset drops to 0, the tab bar slides under the traffic lights, and the close/minimise/zoom hit region sits on top of the first two or three tabs.

The PR already names the robust signal: NSWindow.styleMask & NSWindowStyleMaskFullScreen (1 << 14). getNSWindow in WindowTransparency.kt:190 is private, but widening it is a one-line change and the JNA plumbing is already there. That also makes the placement == Fullscreen half of the || redundant rather than a hedge.

3. setWindowTitle("") at OSC 133;A races the shell's own OSC 2

TabController.kt:156 clears the window title on prompt start. But most shells emit their title from precmd/PROMPT_COMMAND — the same hook that emits 133;A. Ordering between the two is not defined by anything BossTerm controls.

The case the new comment right above it cites is the one that breaks. oh-my-zsh sets OSC 1 + OSC 2 from omz_termsupport_precmd, registered into precmd_functions when oh-my-zsh.sh is sourced — i.e. before a user's BossTerm snippet at the end of .zshrc. zsh runs precmd_functions in array order, so the sequence is OSC 1OSC 2133;D133;A, and the clear lands last. The me@host: ~/src title oh-my-zsh just set is wiped on every single prompt.

Net effect: the "OSC 2 wins" precedence documented at length in TabbedTerminal.kt almost never takes effect for the shells that actually set OSC 2, and which title a user sees depends on their hook registration order. That is a hard-to-report inconsistency.

Clearing on onCommandStarted (OSC 133;B / preexec) instead would fix the ordering: at command start you genuinely want to forget the previous program's title, and a precmd-set OSC 2 then survives the whole prompt. The trade-off is that a shell setting no OSC 2 keeps a stale app title from app-exit until the next command — bounded, and arguably better than the current nondeterminism. Alternatively keep the clear at A but skip it when an OSC 2 arrived since the last B.

4. The empty-string sentinel is only understood by one of three consumers

setWindowTitle("") is a new "reset" signal, but the two other windowTitleFlow consumers both filter it out as a non-event:

  • EmbeddableTerminal.kt:316if (title.isNotEmpty()) onTitleChange(title)
  • tabbed-example/.../Main.kt:375 — same shape via onWindowTitleChange

So for embedders the clear is invisible and the app's title sticks around after it exits, which is the exact bug this PR fixes for the main app. Not a regression, but the fix now lives in one caller instead of in the model. Worth either lifting the resolution into a shared helper (see item 5) or documenting on windowTitleFlow that "" means "reset, fall back".

5. Test coverage is on the parts that can't really break; the part that changed isn't covered

NativeTitleBarStyleTest is well written, but three of its seven assertions are near-tautological — assertEquals(28.dp, NATIVE_TITLE_BAR_HEIGHT) is a change-detector, and the isNativeFullscreen cases exercise Rectangle.equals. The client-property test is the one with real content.

Meanwhile the behaviour that actually changed for every user — the window title precedence — has no test at all, and it is pure logic that is trivially testable once lifted out of the combine:

internal fun resolveWindowTitle(custom: String?, osc2: String, tabTitle: String): String =
    custom ?: osc2.ifEmpty { tabTitle }

Four cases worth pinning: custom wins over OSC 2; OSC 2 wins over the tab title; empty OSC 2 falls back to the tab title (the item 3/4 reset path); all-empty produces empty so the caller's isNotEmpty() guard still suppresses it.

6. Smaller things

  • The window title no longer contains "BossTerm" at all. It is now ~ or src. That string is what macOS shows in the Window menu, in Mission Control labels, and in the Cmd-Tab window list, so BossTerm windows become unidentifiable there among every other app's windows. Most terminals hedge with dir — AppName, or fall back to the app name when the tab title is generic. Worth considering, especially since this same string is now the visible centred text (item 1).
  • The PR description is stale vs. the code. The "Window title" section says "TabController.wireCwdTitle also mirrors OSC 2 onto it alongside OSC 0/1", but the comment added at TabController.kt:169-175 says the opposite and explains at length why not. The code is right; the description should be updated so the merge commit doesn't record the wrong rationale.
  • isNativeFullscreen KDoc overstates the discriminator. "The discriminator is the menu bar" — it is really "occupies the whole display", and the Dock matters as much as the menu bar. Related to item 2; if you move to styleMask this paragraph goes away anyway.
  • The ComponentAdapter registers on every platform even though titleBarInset can only be non-zero when fullWindowContent is true. Wrapping the DisposableEffect body in if (fullWindowContent) (or keying it on that) skips a per-resize Rectangle comparison on Windows/Linux for free, and makes the macOS-only intent read the same way applyFullWindowContent does.
  • println for the failure path matches the convention in this package (GlobalHotKeyManager, WindowVisibilityController), so no objection — just noting it was checked.

Things I checked that are fine: the remember-as-side-effect in Main.kt is reasonable as argued (idempotent client-property write on a stable window, and needing it before first measure is a real constraint). Threading on the new setWindowTitle("") is safe — myApplicationTitleListeners is a CopyOnWriteArrayList and the display setter just writes a MutableStateFlow, so calling it from the emulator thread is fine. And windowTitleFlow being a StateFlow means the new combine has an initial value on both sides and can't stall.

Items 1, 2 and 3 are the ones I'd want resolved; the rest are cleanups.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Two independent changes, both well-scoped: the macOS fullWindowContent styling (genuinely small — three client properties plus one inset value) and the window-title precedence fix (the actual user-visible bug). The platform gating, the best-effort return contract, and the decision to route the inset through a single titleBarInset val rather than duplicating the predicate are all right. The headless-testable JRootPane overload with the platform injected is a nice way to get both branches asserted on every runner instead of each one skipping half.

Findings below, roughly by how much they'd cost to hit.


1. Comment contradicts the implementation it describes

TabbedTerminal.kt:1646:

// ... The prompt-reset listener in TabController clears it on each fresh prompt, so it reverts on exit

The clear happens at command start (133;B), not prompt start — and TabController.kt:154-165 spends a paragraph arguing specifically that it must not be prompt start. ComposeTerminalDisplay.kt and the resolveWindowTitle KDoc both say "command start" correctly, so this is the one copy that drifted. Given how much of this PR's value is in the comments, it is worth fixing.

2. The hook-ordering premise for choosing B over A looks inverted for BossTerm's own integration

TabController.kt:159-161 justifies B with:

// 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

The shipped integration is the other way round. bossterm_shell_integration.zsh is sourced from .zshenv (its own comment at lines 21-23 says so) and calls add-zsh-hook precmd _bossterm_precmd right there — i.e. before .zshrc runs, and therefore before oh-my-zsh appends omz_termsupport_precmd to precmd_functions. Hooks run in registration order, so the real sequence is 133;D, 133;A, OSC 7, then omz's OSC 1 / OSC 2. Same story for preexec: _bossterm_preexec is registered first, so 133;B precedes omz_termsupport_preexec. The README's hand-rolled snippets define plain precmd()/preexec() functions, which zsh also runs ahead of the *_functions arrays — so in both documented setups BossTerm's hook fires first.

Practical upshot: choosing B is still fine (and it is the safer of the two where the ordering is reversed), but the reasoning as written does not match the repo's own shell integration, and the hazard is symmetric — for a user who registers the snippet with add-zsh-hook preexec from .zshrc after omz, clearing at B wipes the command title omz_termsupport_preexec just set, which is the exact failure the comment rejects A for. Worth either correcting the premise or stating the trade-off directly ("neither hook is order-safe in general; B is the one that loses less").

3. The reset is indistinguishable from the app emitting an empty OSC 2

TabController.kt:167 uses session.terminal.setWindowTitle(""), which goes through BossTerminal.changeApplicationTitle and fans out to every TerminalApplicationTitleListener before touching the display (BossTerminal.kt:381-386). So an internal bookkeeping reset is published as if the program had set an empty title. Today the only local consumers are the display writers, but EmbeddableTerminal.kt:316 (onTitleChange) is on that path and is a public embedder callback — it happens to survive because of its isNotEmpty() guard, which is load-bearing by accident rather than by contract.

session.display.windowTitle = "" writes the same MutableStateFlow directly and keeps the reset internal. It also avoids perturbing the XTWINOPS title stack (saveWindowTitleOnStack snapshots myDisplay.windowTitle), which a command started between a push and a pop would now see as "".

4. "Reverts on exit" only holds where OSC 133 reaches

The bundled integration returns early inside tmux/screen and for TERM=dumb (bossterm_shell_integration.zsh:9-16), and plenty of users have no integration at all. In those sessions onCommandStarted never fires, so a title set by vim/ssh keeps naming the window until the next program sets one — unchanged from today, but the new comments state the revert as unconditional. One clause acknowledging it would keep the docs honest.

5. Fullscreen: worth one manual check of the revealed title bar

Dropping the inset to zero in fullscreen is the right call for the steady state. The case I would verify by hand is the transient one: with fullWindowContent, moving the pointer to the top of the screen in native fullscreen slides the title bar back over the content without reflowing it, so the tab bar row — now at y=0 — sits under it while revealed. If that swallows clicks on the first row of tabs, it is the one behavioural regression this PR could introduce for fullscreen users. Not asserting that it does; you have the machine and I do not.

6. Smaller things

  • NATIVE_TITLE_BAR_HEIGHT (NativeTitleBarStyle.kt:18) — a public top-level val with a very generic name in a library module. Cross-module use means it cannot be internal, but it is now part of compose-ui's API surface for embedders; consider hanging it off an object alongside applyFullWindowContent. The 28pt value itself is fine and the KDoc explains why it cannot be measured.
  • remember { } as a side effect (Main.kt:298-301) — the comment pre-empts the objection and the write is idempotent, so this is defensible; the stated reason ("has to run before the content below measures") is slightly off, though, since a SideEffect plus a state read would also settle before the first frame is drawn. Not worth changing — just don't let the comment harden into a rule.
  • println for the failure path — matches the rest of the window/ package (GlobalHotKeyManager, WindowVisibilityController), so no objection; but a runCatching around putClientProperty that the comment itself calls unreachable is a lot of ceremony for the value it returns.
  • Rationale duplication — the AWT-transparency argument now lives in the NativeTitleBarStyle KDoc, the TerminalSettings KDoc, the commit message and the PR body; the OSC 1 vs 2 argument in three places. Finding 1 is what that costs. TerminalSettings already points at applyFullWindowContent; it could stop there instead of restating the short version.

Tests

resolveWindowTitle coverage is exactly right — all five branches including the empty-everything case, with the reasoning for each in the test name. NativeTitleBarStyleTest gets the reachable part of the styling contract, and injecting the platform rather than detecting it is the correct call for a headless matrix.

The gap is findings 2 and 3: the 133;B reset is the behaviour most likely to surprise someone (it changes what the window says for the duration of every command in some setups) and it has no test. BossTerminal.processShellIntegration('B', …) → listener → display.windowTitle is exercisable without a TabController if the reset moves to display as suggested in finding 3; combined with resolveWindowTitle that would cover the full "app exits, window reverts to the tab title" story rather than just its second half.

Performance / security

Nothing. combine over a snapshotFlow re-emits on tab-title changes that OSC 2 then overrides, but onWindowTitleChange writes a MutableState, so an identical value is a no-op — no distinctUntilChanged needed. The strings reaching window.title are attacker-influenceable in the same way they already were; the tab-title fallback does not add a source that was not already displayed.


Good bug to have caught while looking at something else — a window title that never moved is the kind of thing that goes unnoticed for years. Findings 1 and 3 are the two I would fix before merge; 2 and 4 are comment corrections; 5 is a five-second check on the machine.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review — feat(window): unified native title bar, and a window title that is actually dynamic (1/2)

Nice change, and an unusually well-argued one. Two things stand out as genuinely good practice: extracting the two decisions that can be asserted headlessly (resolveWindowTitle, applyFullWindowContent) instead of shrugging at "it's UI, can't test it", and using client properties rather than the JNA/getNSWindowPtr reflection ladder that WindowTransparency.kt already carries — this is inert, version-safe, and degrades to a no-op. Recording the negative result on transparency in the KDoc, rather than in a PR description nobody will find again, is also the right call.

Most of what follows is one real regression plus a few structural suggestions.


1. Command-completion notifications regress to the literal "BossTerm" — blocking

CommandNotificationHandler takes its notification title straight from the OSC 2 value:

// TabController.kt:553 (also :938, :1184)
tabTitle = { display.windowTitle?.ifEmpty { "BossTerm" } ?: "BossTerm" }

and reads it at command finish (CommandNotificationHandler.kt:87, inside onCommandFinished). The new clear at TabController.kt:178 empties display.windowTitle at 133;B — the start of the very command whose 133;D fires that notification. So for the whole duration of every command, unless the running program itself sets OSC 2, the notification title is now "BossTerm" (or sessionTitle on the remote paths).

The affected population is precisely the one that previously worked: oh-my-zsh and the PROMPT_COMMAND bash setups that emit OSC 2 from precmd used to produce me@host: ~/src on the notification; they now produce BossTerm. And notifications only fire when the window is unfocused — i.e. exactly when the title is the only thing telling you which session finished. With several tabs running long builds they now all say the same word.

The fix is small and makes the notification obey the same precedence the PR just established for the window:

tabTitle = { resolveWindowTitle(tab.customTitle.value, display.windowTitle.orEmpty(), tab.title.value)
                 .ifEmpty { "BossTerm" } }

(resolveWindowTitle is internal in ai.rever.bossterm.compose, so it is reachable from compose.tabs — same module.) That also lands the PR's own stated principle in one more place: notification, window and tab all agree.

2. Worth confirming: the window title now churns twice per command for shells that do emit OSC 2

Same clear, different consequence. On oh-my-zsh the title goes me@host: ~/src (precmd) → src (the 133;B clear, falling back to the tab title) → me@host: ~/src (next precmd), for every command. The PR describes the cost as "losing a per-command title for the length of one command", which is accurate but reads as a rare edge; for an omz user it is visible at every prompt.

The A-vs-B reasoning is sound and I don't think there is a strictly better hook — 133;D has the same ordering race, since shells emit D and the precmd OSC 2 from the same hook. But if the churn is unwanted, clearing only when the value has changed since the last 133;B (i.e. discarding only a title the exiting program itself set) would keep the shell's own title stable. Flagging it as a call to make deliberately rather than discover.

3. Light Mode: this ships a defect with no user-facing escape

The KDoc note in NativeTitleBarStyle.kt is honest and I would rather have it than not. But what it describes is a user in macOS Light Mode getting a near-black window title on a dark terminal background, in the default configuration (useNativeTitleBar = true), with no setting to turn it off — the only escape is switching to the custom title bar, which costs a restart and native fullscreen. That is more than a documentation item.

Two mitigations that do not need JNA:

  • The app already passes -Dapple.awt.application.appearance=system (bossterm-app/build.gradle.kts:184). Pinning it to NSAppearanceNameDarkAqua fixes the title colour outright. It darkens all AWT chrome too, which for a terminal is arguably the right default — but it is a global change, so it deserves its own decision.
  • Or set apple.awt.windowTitleVisible = false on the light-appearance path and let the tab bar carry the name. Loses the title in that one case, gains legibility.

Either way I would open a follow-up issue so it is not only recorded in a comment.

4. remember for a side effect (Main.kt:288-300)

The comment defends this as "must run before content measures", and the goal is right — but the two concerns separate cleanly, giving you first-frame layout and a mutation in the effect phase:

val fullWindowContent = remember { useNativeTitleBar && ShellCustomizationUtils.isMacOS() }
SideEffect { if (fullWindowContent) applyFullWindowContent(this@Window.window) }

The inset only needs to know whether the style will be applied, and that is a pure predicate — it does not need the putClientProperty to have already run. Composition can be discarded or re-run, and remember's initializer is not guaranteed to belong to the composition that gets applied; the write happening to be idempotent is what makes today's version survive, not the structure. (One divergence: applyFullWindowContent also returns false for a null root pane, which ComposeWindow — a JFrame — never is.)

@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review (2/2)

5. Extract the inset predicate — it is the subtlest decision here and it is untested

titleBarInset (Main.kt:765-771) carries the Fullscreen-yes / Maximized-no distinction, ~18 lines of hard-won rationale, and the comment itself notes two copies of this predicate already drifted apart. It is also the only new decision with no test. All three go away with:

// NativeTitleBarStyle.kt
fun titleBarInset(styleApplied: Boolean, placement: WindowPlacement): Dp =
    if (styleApplied && placement != WindowPlacement.Fullscreen) NATIVE_TITLE_BAR_HEIGHT else 0.dp

Three assertions (fullscreen → 0, maximized → 28, not-applied → 0), the rationale moves to KDoc beside the constant it is about, and the composable gets ~20 lines shorter. WindowPlacement is a plain enum, so this stays headless-safe like the rest.

6. NATIVE_TITLE_BAR_HEIGHT — public surface, and the magic number

  • It is a public top-level val in a library module that embedders (terminal-tab/BOSS) consume. Unless an embedder needs it, internal — or hanging it off a NativeTitleBarStyle object — keeps the exported API smaller.
  • 28pt is correct for a standard titlebar today, and the "can't measure it once fullWindowContent is on" reasoning is right. But it is measurable before the properties are applied: a displayable decorated JFrame reports the titlebar height in window.insets.top. Since applyFullWindowContent already has the window, window.insets.top.takeIf { it > 0 } with 28 as the fallback would self-correct if AppKit ever changes it. Optional — the failure mode is a few points of gap, not a broken window.

7. Minor

  • No distinctUntilChanged() on the combined title flow (TabbedTerminal.kt:1650-1658). combine re-emits when either side changes, so onWindowTitleChange can fire repeatedly with the same string — e.g. renameChip writes customTitle then title as two separate snapshot writes (TabbedTerminalState.kt:320-323). Setting an equal MutableState is a no-op for Compose, but window.title bottoms out in Frame.setTitle → an AppKit call. One operator.
  • resolveWindowTitle assumes a non-blank custom. Both rename paths normalise blank → null (TabbedTerminal.kt:1542, TabbedTerminalState.kt:317), so it is safe today — but custom ?: … would return "" for a whitespace rename, and the caller would silently suppress the update while the tab label goes blank. Either custom?.ifBlank { null } ?: …, or a line in the KDoc naming the precondition.
  • Test gaps beyond the above: nothing covers the onCommandStarted clear or its interaction with the notification handler — item 1 would have been caught by a test asserting the notification title across a B/D cycle. Understandably awkward (the listener is an anonymous object inside a private wireCwdTitle), but the notification-title lambda is injectable, so it is reachable.
  • Docs: the light-mode caveat and the AWT-transparency negative result are exactly the kind of "measured, don't rediscover" note AGENTS.md collects under Critical Technical Patterns. A three-line entry there would surface it earlier than the KDoc will.

Things I checked and found fine

  • Platform gating — off macOS applyFullWindowContent returns false → inset 0 → today's behaviour exactly, on both the spacer and the hotkey-hint paths.
  • Density28.dp resolves to 28pt on both retina (density 2) and non-retina, so the constant is in the right units.
  • The tab-label / OSC 1 split — leaving iconTitleFlow as the only tab-label source is right, and the oh-my-zsh short-OSC-1/long-OSC-2 justification (TabController.kt:265-270) matches what that shell actually emits.
  • Other windowTitleFlow consumersEmbeddableTerminal.kt:316 guards on isNotEmpty() so the reset cannot blank an embedder's title, and the daemon-side TerminalSessionCore/SessionHost/DaemonShareServer reads go through a different display that TabController never touches. The notification handler in item 1 is the one that is not guarded.
  • Tab-switch behaviour — keying the LaunchedEffect on activeTab/focusedPaneId plus the tab-title fallback actually fixes a second latent bug: switching to a tab whose OSC 2 was empty used to leave the window showing the previous tab's title.
  • Security / perf — nothing to raise. No I/O, no reflection, no native calls; two inert client properties and one extra combine per focused pane.

Item 1 is the one I would want fixed before merge; 3 and 5 are worth doing, and the rest are take-or-leave.

…cations

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Read the whole diff plus the surrounding code in Main.kt, TabController.kt, ComposeTerminalDisplay.kt, BossTerminal.kt and the shell-integration scripts. This is a strong PR: the fullscreen-vs-maximized distinction is the subtle part and it is both correct and tested, the "what this is not" section saves the next person an afternoon on AWT transparency, and splitting the pure predicate (titleBarInset) from the mutation is the right shape for a headless CI. 28.dp is also the right units — on macOS Compose density tracks the screen scale, so 28 dp resolves to 28 points on both Retina and non-Retina.

Findings roughly by how much they matter.


1. applyFullWindowContents return value is discarded at the only call site

NativeTitleBarStyle.kt documents the return as "so callers know whether to inset their content", and NativeTitleBarStyleTest opens with "the return value decides whether the window reserves NATIVE_TITLE_BAR_HEIGHT". But Main.kt:300-307 throws it away:

val fullWindowContent = remember { useNativeTitleBar && ShellCustomizationUtils.isMacOS() }
SideEffect { if (fullWindowContent) applyFullWindowContent(this@Window.window) }   // result ignored
...
val titleBarInset = titleBarInset(fullWindowContent, windowState.placement)         // predicate, not result

So on the decline paths the function exists to protect against — no root pane, or the runCatching failing — the window still reserves 28 dp and you get exactly the "strip of background above the tabs" the test comment says false prevents. In practice ComposeWindow is a JFrame and putClientProperty will not throw, so this is unlikely to fire; the problem is that the documented contract has no production consumer, which makes the tests assert something no caller depends on.

Cheap fix that keeps the "right on the first frame" property you argued for:

var styleApplied by remember { mutableStateOf(fullWindowContent) }   // optimistic, correct on frame 1
SideEffect { if (fullWindowContent) styleApplied = applyFullWindowContent(this@Window.window) }
val titleBarInset = titleBarInset(styleApplied, windowState.placement)

If you would rather keep it as-is, the KDoc and test comments should say the inset is driven by the predicate rather than by the result — otherwise the next reader trusts a guarantee that is not wired up.


2. The notification fallback can name a program that already exited

display.iconTitle is never reset — the only writes are from OSC 0/1 (TabController.kt:546, :940, :1193). The new chain is:

tabTitle = { display.windowTitle?.ifEmpty { null } ?: display.iconTitle?.ifEmpty { null } ?: "BossTerm" }

Combined with the OSC 2 clear at prompt start: run vim (sets OSC 1 vim), quit it, then run a long ./gradlew build in an unfocused window → the OSC 2 slot is empty (cleared at the prompt) and the notification says "vim". That is the same staleness this PR fixes for OSC 2, moved one slot down.

There is a gap in the other direction too: the bundled integration emits only OSC 7 and OSC 133 (bossterm_shell_integration.zsh), so on a plain zsh/bash with no title-setting precmd both slots are empty and every tab is back to announcing "BossTerm" — the case the comment says the change is for.

session.title fixes both: it is reset on every prompt start (right above the new clear), it already mirrors OSC 1, and it falls back to the cwd label — and it is what resolveWindowTitle uses, so the window title and the notification would finally agree on which session finished. The handler is constructed before the TerminalTab exists, but tabTitle is a lazily-invoked lambda, so a captured var tabRef: TerminalTab? = null assigned after construction is enough.


3. ifEmpty vs ifBlank is inconsistent with its own justification

resolveWindowTitle carefully uses ifBlank for custom"a whitespace custom title would otherwise win and blank the window" — then uses ifEmpty for osc2. An app emitting a whitespace-only OSC 2 hits precisely the rejected case: whitespace wins and the window looks nameless. Same for display.windowTitle?.ifEmpty { null } in the three notification lambdas. Suggest ifBlank throughout, plus a resolveWindowTitle(custom = null, osc2 = " ", tabTitle = "src") case.


4. The XTWINOPS claim in the new comment is not quite right

TabController.kt:180 says the clear "keeps the XTWINOPS title stack out of it". It keeps push/pop from being triggered, but BossTerminal.saveWindowTitleOnStack() reads the very field being cleared:

override fun saveWindowTitleOnStack() {
    val title = myDisplay.windowTitle    // now "" after any prompt start

So an app doing CSI 22;2tCSI 23;2t around itself pushes "", and the restore publishes an empty title through changeApplicationTitle to every application-title listener. Narrow, and mostly pre-existing since shells that never emit OSC 2 already pushed "" — but the comment reads as a stronger guarantee than holds.


5. Smaller things

  • SideEffect runs on every recomposition of the window scope, not once. Harmless — putClientProperty short-circuits when old and new values are equals, and boxed true is cached — but LaunchedEffect(fullWindowContent) or DisposableEffect(window) says "once per window" and reads as intent rather than as a per-recomposition write.
  • Shadowing: val titleBarInset = titleBarInset(fullWindowContent, …) (Main.kt:757) compiles only because a Dp is not invokable. topInset / titleBarInsetDp saves the reader a check.
  • One-shot OSC 2: a title set once from .zshrc (not from precmd) survives the .zshenv-time 133;A but is discarded by the first real prompt and never returns. It degrades to the tab title, so it is defensible and the trade-off is documented — worth a line in the docs if it comes up.

Test coverage

The two pure functions are well covered, and injecting isMacOS so both branches run on every runner is the right call. What is missing is the two pieces of behaviour users will notice, both testable headless:

  • wireCwdTitles prompt-start reset — fire onPromptStarted(), assert display.windowTitle == "" and that session.title reverted to the cwd label (and that a customTitle still wins).
  • the notification tabTitle provider — the OSC 2 → OSC 1 → fallback precedence, which is where finding 2 lives. Three lines would have caught the stale-vim case.

Minor: assertEquals(NATIVE_TITLE_BAR_HEIGHT, titleBarInset(true, …)) is tautological against the constant. Fine given the value can only be judged by eye, but worth knowing the tests cannot catch a wrong 28.


Not a concern

No security issues — the OSC-derived title reaching window.title and the notification body predates this PR, and nothing new is executed or persisted. Performance is fine: distinctUntilChanged ahead of the AppKit-backed window.title write is the right guard, and the inset is a pure Dp computed in composition.

Nothing here is a blocker except arguably 1 and 2, and both are small edits.

…rogram

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Read the diff against the surrounding code (Main.kt window scope, TabController.wireCwdTitle, CommandNotificationHandler, and all three bundled shell-integration scripts). The core of this is right, and the reasoning in the PR body is unusually well supported — I checked the load-bearing claim and it holds on all three shells, not just zsh:

shell who runs first verdict
zsh sourced from .zshenv, add-zsh-hook precmd registers before .zshrc
bash PROMPT_COMMAND="__bossterm_prompt_command; $PROMPT_COMMAND" — prepended
fish vendor_conf.d, loads before user config

And in every one of them 133;D is emitted immediately before 133;A, while CommandNotificationHandler.onCommandFinished calls tabTitle() synchronously — so the notification reads the OSC 2 title before the clear wipes it. That ordering is the whole thing and it's sound.

Splitting applyFullWindowContent/titleBarInset/resolveWindowTitle into pure functions so a headless runner can execute them is the right call, and the JRootPane overload with an injected isMacOS is a genuinely good workaround for "no java.awt.Window exists in headless CI".

Findings below, roughly by impact.


1. The Light Mode title regression ships default-on, and the escape hatch is closer than the KDoc says

useNativeTitleBar = true is the default, so every macOS user running a light system appearance gets a near-black title drawn over the (dark by default) terminal background the moment this merges. Tracked in #368, but there's no opt-out short of flipping to the custom title bar — which costs proper fullscreen. That's a real trade for a purely cosmetic change.

The KDoc frames the fix as expensive:

fixing it means either forcing the NSWindow appearance through JNA or hiding the system title and drawing it in the reserved strip

Both are cheaper than that reads, because the JNA machinery already exists in this same package. window/WindowTransparency.kt has a working ObjectiveC binding (objc_getClass / sel_registerName / objc_msgSend, Native.load("objc") with a null-on-failure guard) and already resolves NSWindow pointers. So the "forcing" route is roughly:

[nsWindow setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]]

— a handful of lines on top of infrastructure that is already loaded, already null-guarded, and already macOS-gated, not a new dependency or a new native surface.

If that's out of scope for this PR, there's a one-line mitigation available right now: flip apple.awt.windowTitleVisible to false when the resolved background is dark and the system appearance is light. Nothing is actually lost — the tab bar already shows the session name, which after commit 2 is the same string the window title resolves to in the common case. An unreadable title is strictly worse than no title.

At minimum, please update the KDoc to point at WindowTransparency.kt, so whoever picks up #368 doesn't re-derive the ObjC bridge from scratch.

2. tabRef is a late-assigned captured var read from another thread

TabController.kt ~557, ~955, ~1214:

var tabRef: TerminalTab? = null
val notificationHandler = CommandNotificationHandler(
    tabTitle = { notificationTitle(custom = tabRef?.customTitle?.value, ...) }
)
...
tabRef = tab   // ~100 lines later

A captured var compiles to a non-volatile Ref.ObjectRef field. The write happens on the constructing thread; the read happens on the terminal reader thread (that's where onCommandFinished dispatches from). There is no happens-before edge between them, so the reader is not guaranteed to observe the assignment.

In practice this is unreachable — a notification requires notifyMinDurationSeconds (default 5s) of command time plus an unfocused window, and the failure mode is a graceful fall back to "BossTerm". But AtomicReference<TerminalTab?> costs nothing and removes the need for anyone to reconstruct that argument later. The comment currently says "the lambda is only invoked at command finish", which explains the ordering but not the visibility; those are separate guarantees and only the first is addressed.

(The customTitle.value / title.value snapshot reads from off the UI thread are pre-existing — wireCwdTitle already writes session.title.value from onPromptStarted — so no change needed there.)

3. Three verbatim copies of the notification-title lambda

The same 7-line block plus the same 4-line comment now appears at ~560, ~960, ~1220. Extracting notificationTitle was exactly the right instinct; this is the remaining 80% of the duplication:

private fun notificationTitleProvider(
    display: ComposeTerminalDisplay,
    tab: () -> TerminalTab?,
    fallback: String,
): () -> String = {
    notificationTitle(
        custom = tab()?.customTitle?.value,
        osc2 = display.windowTitle.orEmpty(),
        tabTitle = tab()?.title?.value.orEmpty(),
        fallback = fallback,
    )
}

Worth doing on its own merits, but especially here: NativeTitleBarStyle.kt's own KDoc documents that duplicating this exact kind of predicate is what caused the last drift —

Repeating it is exactly what went wrong once already: two copies drifted, one of them handled fullscreen and the other did not

— and this PR introduces three copies of a sibling one. It also folds naturally into fix #2, since the helper can own an AtomicReference instead of each call site declaring its own var.

4. Test coverage: the behaviour that actually changed is the untested one

WindowTitleResolutionTest is good — the whitespace and "stays empty for the caller to suppress" cases in particular are the ones that would otherwise bite. But it covers the two pure functions, and the riskiest change in the PR is the side effect: session.display.windowTitle = "" inside onPromptStarted.

That single line is what the entire precedence chain rests on, it carries ~30 lines of rationale defending a choice that was reverted once, and it has zero tests. A ComposeTerminalDisplay-level test — drive onPromptStarted(), assert windowTitleFlow.value == "" — would be cheap and would lock in the contract. It would specifically catch someone later "cleaning this up" by routing through terminal.setWindowTitle(""), which the comment correctly identifies as wrong (it would publish an empty title to EmbeddableTerminal.onTitleChange, which only survives today by way of its isNotEmpty() guard at EmbeddableTerminal.kt:317).

applyFullWindowContent(Window) being untested is fine and correctly acknowledged.

5. println on the failure path

NativeTitleBarStyle.kt uses println for the "something is badly wrong" branch. stdout is the one channel you don't want in a terminal emulator. The codebase has both alternatives already: slf4j (CommandNotificationHandler) and System.err (ComposeTerminalDisplay.logError). Either beats stdout.

6. Low confidence, worth watching during manual testing: per-prompt title churn

On oh-my-zsh, the clear at 133;A and the shell's own OSC 2 from precmd land back to back, so the resolved title goes me@host: ~/srcsrcme@host: ~/src on every prompt. MutableStateFlow and combine both conflate, and the two writes are microseconds apart on the emulator thread, so the collector almost certainly never observes the intermediate — but conflation is a "may", not a "must", and the collector is on the composition dispatcher. If you see the title strobe on cd, a debounce(16) before distinctUntilChanged closes it. Not a blocker, just a thing to watch now that you have the manual-test setup up.

7. Nits

  • The double remember (Main.kt:302). useNativeTitleBar is already remembered at line 265 and isMacOS() is a constant, so remember { useNativeTitleBar && isMacOS() } wraps something already stable. The comment justifying it argues about remember's initializer not belonging to the applied composition — true in general, but the computed value can't vary here. Harmless, just an extra layer.
  • The same rationale now lives in three places — the TabController comment block, the ComposeTerminalDisplay.windowTitleFlow KDoc, and the PR body. This repo does document hard-won facts heavily and that's the house style, but three copies of one argument is the thing that drifts. Consider making the KDoc canonical and having the call site point at it.
  • AGENTS.md wasn't updated. Given the convention there of recording exactly this class of platform fact (the dyld loader-lock note, the macOS-only context-menu note), two findings here look like AGENTS.md material rather than KDoc-only: (a) AWT cannot give a decorated frame an alpha-capable backing store, measured — a "do not spend an afternoon rediscovering it" fact of precisely the kind AGENTS.md already collects; and (b) the OSC 1 = tab / OSC 2 = window split, now that it's load-bearing across three files. window/NativeTitleBarStyle.kt is also worth a line in Key Files.
  • 28dp carries one assumption the KDoc doesn't list: it's also wrong if AppKit adds a native window-tab bar above the content. Almost certainly unreachable for AWT frames — noting it only so the constant's assumptions are complete.

Verdict: the mechanism is correct and the shell-ordering argument checks out on all three integrations. Nothing here is a merge blocker except the judgement call in #1 — shipping a default-on change that leaves the window title unreadable for light-appearance users, when the fix turns out to reuse a JNA bridge already sitting in the same package. Either take the short setAppearance: route, or hide the title in the mismatch case and let the tab bar carry the name. #2 and #3 are quick and worth folding in together.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review — #367 unified native title bar + dynamic window title

Read through all ten files plus the surrounding call sites (ComposeTerminalDisplay, EmbeddableTerminal, BossTerminal.saveWindowTitleOnStack, the three TabController session-creation paths, and the daemon-side windowTitle consumers).

This is a strong PR. Two things stand out as genuinely well done:

  • The layering is right. Splitting applyFullWindowContent into a Window overload and a JRootPane + injected-isMacOS overload is the only way to get any of this under test on a headless runner, and the reasoning is written down instead of assumed. Same for titleBarInset existing as one function rather than a predicate repeated at two call sites — the comment naming that as an already-observed drift is exactly the right justification.
  • The Fullscreen-vs-Maximized distinction, and tracing WindowPlacement down to osxIsFullscreenNative rather than trusting it. That is the subtle one, it is correct, and a zoomed window still reserves the strip pins it.

Preserving the OSC 1 / OSC 2 split rather than collapsing it is also the right call, and the oh-my-zsh precmd case is the correct reason.

Findings below, roughly by value.


1. Three comments say "command start" where the code does prompt start — including the one on the public flow

This is the PR's most carefully-argued decision (TabController.kt:160: "Prompt start, NOT command start. Both were tried."), and three places contradict it:

  • ComposeTerminalDisplay.kt:135"TabController clears it at each command start". This is the KDoc on windowTitleFlow, i.e. the surface a consumer reads to understand why empty means reset.
  • TabbedTerminal.kt:1646"TabController clears it at each COMMAND start", capitalised for emphasis, so it reads as deliberate rather than as a slip.
  • WindowTitleResolutionTest.kt:36"Empty is the RESET written at each command start".

resolveWindowTitle's own KDoc (TabbedTerminal.kt:2763) says prompt start, and onPromptStarted is where the write actually is. The next person to touch this finds both claims and has to re-derive which is true — precisely what the long comment was written to prevent. Three-word fix, but worth doing before merge given how load-bearing the rest of the documentation is.

2. onWindowTitleChange now fires far more often, and it invalidates the outer composable scope

Before, the callback fired only on OSC 2, which most shells never emit. Now it fires on the resolved title, and session.title is written by two hot sources: the cwd snapshotFlow (TabController.kt:146) and the OSC 1 mirror (TabController.kt:203). distinctUntilChanged() is the right guard and does catch the rename double-write it was added for, but it does nothing for a title that genuinely changes on a timer — and putting progress in the title is a common TUI idiom (npm, wget, download managers, some agent CLIs).

Each distinct value lands on window.title.value, which is read at Main.kt:275 in the scope that calls Window(...), so it invalidates that scope rather than a leaf, and bottoms out in JFrame.setTitle → AppKit on the EDT. Probably fine; worth 30 seconds of hand-checking against something that rewrites its title continuously, and a sample(...) on the flow is cheap insurance if it is not. Note this is also a visible behaviour change for TabbedTerminal embedders passing onWindowTitleChange — BossConsole's window title will now move on every cd.

3. Fullscreen: the inset is zero, but macOS reveals the title bar on a top-edge hover

titleBarInset correctly returns 0 in Fullscreen, and the no-dead-band reasoning is right for the steady state. But in native fullscreen macOS slides the title bar down over the content when the pointer reaches the top edge — and with fullWindowContent the tab bar is now at y=0 there, so the revealed bar lands on top of it. Same failure the inset exists to prevent (tab bar under the traffic lights), just transient and hover-triggered.

Worth 10 seconds in the running app: enter fullscreen, push the pointer to the top, check whether the first tab is still clickable while the bar is showing. If it is not, the options are the usual ones (keep the inset in fullscreen, or hook the fullscreen/toolbar-reveal notifications). Either way the answer belongs in the KDoc, since the current text implies fullscreen is simply a non-issue.

4. notificationTitle shadows notificationTitle, and both helpers live in a 2,790-line composable file

resolveWindowTitle and notificationTitle are top-level functions declared at the bottom of TabbedTerminal.kt and imported into TabController.kt. Then at three sites in TabController (:558, :945, :1193) a local val notificationTitle = NotificationTitleProvider(...) shadows the imported function of the same name. It compiles — the call inside NotificationTitleProvider.invoke is in a different scope — but a reader hitting notificationTitle(...) at :2202 has to work out which of the two it is.

Two small moves fix both: pull the pair into their own file (tabs/WindowTitle.kt or window/WindowTitle.kt — they are title-resolution policy, nothing to do with the TabbedTerminal composable), and rename the local to notificationTitleProvider.

Minor, same file: import java.util.concurrent.atomic.AtomicReference is spliced into the middle of the ai.rever.* import block at TabController.kt:304.

5. Test coverage misses the two changes most likely to regress

The pure-function coverage is good and the choice of what to assert is right. What is not covered is the stateful half:

  • onPromptStarted clearing display.windowTitle. This is the behaviour change with the widest blast radius — it feeds resolveWindowTitle, NotificationTitleProvider, EmbeddableTerminal.onTitleChange (which survives it only via its isNotEmpty() guard), and saveWindowTitleOnStack. A ComposeTerminalDisplay plus a direct onPromptStarted() call on the listener would pin it; the obstacle is that the listener is constructed inline inside wireCwdTitle, so it is not reachable without hoisting it.
  • NotificationTitleProvider before attach(). The KDoc asserts "the lambda is not invoked before a command finishes" — an ordering claim spanning three call sites, exactly the kind that rots. It is private, so untestable; internal would make it a three-line test that the null-tab path yields the fallback (i.e. pre-PR behaviour).

The uncovered as? RootPaneContainer branch in the Window overload is fair enough — there is no headless way to build one.

6. Smaller notes

  • runCatching catches Throwable. applyFullWindowContent runs inside a LaunchedEffect, so in principle a CancellationException crossing it would be swallowed and reported as a style failure. putClientProperty cannot realistically produce one, so this is theoretical — but try { ... } catch (e: Exception) costs nothing and says what is meant. (System.err.println over a logger matches WindowTransparency.kt in the same package, so no complaint there.)
  • NATIVE_TITLE_BAR_HEIGHT = 28.dp is unverifiable at runtime. The justification for the constant is sound — fullWindowContent breaks the measurement. Just noting the failure mode is silent: if the real bar is ever not 28pt, the reserved strip and the traffic lights disagree with nothing to indicate why. Not worth the ObjC bridge to fix, given the KDoc already flags that bridge as unproven.
  • TerminalSettings.kt:273 links [ai.rever.bossterm.compose.window.applyFullWindowContent], which is now overloaded — Dokka will likely warn on the ambiguous reference.
  • Comment density. Content is excellent; volume is high in places — roughly 30 lines of comment for session.display.windowTitle = "", much of it now duplicated verbatim into AGENTS.md. Since those AGENTS.md sections exist, the call sites could shrink to the decision plus a pointer, leaving one copy to keep true. Finding 1 is a direct consequence of there being several.

Nothing here blocks except finding 1, which is a three-word correction in three places. Findings 2 and 3 are hand-checks in the running app rather than code changes. Nice piece of work — the AGENTS.md additions on AWT transparency and the OSC 1/2 split are the kind of thing that saves someone a day.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review: feat(window): unified native title bar, and a window title that is actually dynamic

Read the whole diff plus the surrounding call sites (Main.kt, TabController.wireCwdTitle, CommandNotificationHandler, BossTerminal.changeApplicationTitle, the other windowTitleFlow consumers). Overall this is high-quality work — the split between a pure predicate (titleBarInset) and the mutation (applyFullWindowContent), the injected isMacOS so both branches run on a headless runner, and the negative results written down in AGENTS.md instead of being lost are all exactly right. I could not find a correctness bug. What follows is a handful of smaller things, roughly in descending order of value.

Correctness / behaviour

1. LaunchedEffect vs. the first framebossterm-app/.../Main.kt:308-313

styleApplied starts optimistic, so the 28dp Spacer is reserved during the first composition, but the client-property write only lands when the LaunchedEffect coroutine is dispatched. For the frames in between the OS is still drawing its own strip above the content and the content is already inset — i.e. the one combination the inset exists to avoid, doubled. The comment argues the mutation "belongs in the effect phase", which I agree with, but SideEffect { } is also the effect phase and runs synchronously during apply, before the frame is drawn. That keeps the stated invariant and removes the window entirely:

SideEffect {
    if (fullWindowContent && !styleAttempted) {
        styleAttempted = true
        styleApplied = applyFullWindowContent(this@Window.window)
    }
}

(In practice this may well be zero visible frames on most machines — worth an eyeball at launch rather than a rewrite on my say-so.)

2. NATIVE_TITLE_BAR_HEIGHT could be measured before it becomes unmeasurableNativeTitleBarStyle.kt:19

The KDoc's reasoning is right — once fullWindowContent is on, window.height - contentPane.height is zero. But the window is a decorated JFrame at the moment applyFullWindowContent is called, and AWT on macOS reports the title bar height in window.insets.top at that point. Capturing it before the first putClientProperty and falling back to 28.dp would make the value survive an Apple metric change or a non-regular window size, at the cost of a few lines:

val measured = window.insets.top.takeIf { it in 1..80 }?.dp ?: NATIVE_TITLE_BAR_HEIGHT

Not a blocker — 28pt is correct today — but it turns a constant that can silently go wrong into one that can't.

3. isNotEmpty() where the resolver normalises blankTabbedTerminal.kt:1659

resolveWindowTitle deliberately treats blank as empty for custom and osc2 (and there's a test for it), but tabTitle passes through verbatim and the collector guards on isNotEmpty(). A whitespace-only tab title would therefore win and retitle the window to nothing — the exact outcome the ifBlank calls exist to prevent. cwdLabel can't produce one today, so this is theoretical, but isNotBlank() at the collector costs nothing and closes the gap consistently.

4. Off-thread snapshot reads in NotificationTitleProviderTabController.kt:2181-2192

The KDoc reasons carefully about the happens-before edge for the tab reference, which is correct and a nice catch. Worth one more sentence though: what it then reads (tab.title.value, tab.customTitle.value) is Compose snapshot state read from the terminal reader thread, which sees the last applied global snapshot rather than the UI thread's pending one. That's safe (no tearing, no crash) but can lag a frame — and onPromptStarted writes session.title.value from that same reader thread, so the two are at least consistent with each other. As written, a reader could take the AtomicReference note to mean the whole read is fully ordered.

5. Remote sessions never clear OSC 2TabController.createRemoteSession (line 715)

wireCwdTitle is called from createTab, createSessionForSplit and createTabWithPreConnect, but not from createRemoteSession. So on a remote tab the OSC 2 slot is never reset at prompt start and a program that set a window title keeps naming the window after it exits — the behaviour this PR fixes everywhere else. Not a regression (remote tabs behaved this way before too, and they also have no CommandNotificationHandler), just an inconsistency that is now the odd one out. A one-line wireCwdTitle(session) there, or an explicit "remote sessions deliberately don't get this" comment, would stop the next reader wondering.

Maintainability

6. Three copies of the transparency rationale. AGENTS.md, the TerminalSettings.useNativeTitleBar KDoc and the applyFullWindowContent KDoc each carry a version of the AWT / IllegalComponentStateException / no-alpha-channel finding. The finding is genuinely worth recording — but three prose copies is three things to keep in sync, and the shortest-lived one will drift first. Suggest keeping the full argument in exactly one place (the NativeTitleBarStyle KDoc reads like the canonical one) and making the other two a pointer.

7. AGENTS.md "Last Updated" still says July 28, 2026 while the file gained two new sections. Trivial.

Test coverage

The two pure functions are well covered, and WindowTitleResolutionTest reads as documentation of why each precedence rung exists rather than just asserting the table — nice. NativeTitleBarStyleTest asserting both platform branches on every runner is the right call.

The untested surface is the wiring, which is where the two subtle decisions actually live:

  • the snapshotFlow { … }.combine(windowTitleFlow) pipeline in TabbedTerminal.kt (does a rename really emit once after distinctUntilChanged? does focus moving between split panes re-target?), and
  • the prompt-start clear in wireCwdTitle — specifically that onCommandFinished (133;D) reads OSC 2 before onPromptStarted (133;A) blanks it. That ordering is the whole reason the reset lives on A rather than B, per the PR description, and nothing currently fails if someone moves it.

Both are hard to reach today because they are inline in a large composable and in a TabController private. If the second one were extracted (e.g. a small TitleResetListener(session) class alongside NotificationTitleProvider), a test could drive onCommandFinished then onPromptStarted against a ComposeTerminalDisplay and pin the ordering directly — probably the highest-value test still missing here.

Also: NotificationTitleProvider is only tested in its unattached state. The attached path (tab.title fallback beating a stale display.iconTitle) is the regression the class was written for, and it is asserted only through the pure notificationTitle function, not through the provider.

Performance / security

Nothing of concern. distinctUntilChanged() before an AppKit setTitle is the right instinct; the extra Pair per emission is noise. titleBarInset is a pure comparison recomputed per recomposition, which is fine. No new I/O, no new native calls beyond three putClientProperty writes, nothing touching credentials or the share/daemon paths.

Conventions

Matches the repo: ShellCustomizationUtils.isMacOS() rather than raw os.name, System.err.println consistent with the rest of window/ (WindowTransparency.kt), no backwards-compat shims, comment density in line with the surrounding code. The pointer from the NativeTitleBarStyle KDoc to WindowTransparency.kt — including the warning that its only consumer is commented out as unproven — is the kind of thing that saves the next person a day.

Nice work. Nothing here blocks merge; (1), (3) and (5) are the ones I would act on.

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.
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review

Nice piece of work. The fullWindowContent / transparentTitleBar pair is the right route, the split of applyFullWindowContent into a JRootPane overload so headless CI can execute both branches is a good call, and separating titleBarInset into one function that every consumer calls (rather than two copies that drift) is exactly the fix for the class of bug the KDoc describes. Fixing the static window title while in here is a real user-visible win, and keeping OSC 1 / OSC 2 split rather than folding them is correct — the oh-my-zsh precmd case is the one that would have bitten.

The commentary is unusually good; most of what follows is about the seams between the new code and settings that change at runtime.


1. The derived appearance never takes effect for the user who turns the native title bar on — and goes stale on a theme switch (highest-value item)

nativeTitleBarAppearance is applied exactly once, in main() (Main.kt:88-94), because apple.awt.application.appearance is read at AWT init. Two live settings feed it, and both can change without a process restart:

a) The useNativeTitleBar toggle. Flipping it in Settings shows the "Restart Required" dialog, whose onRestartApp in Main.kt is:

WindowManager.closeWindow(window.id)
WindowManager.createWindow()

Same JVM. The new window composition re-reads useNativeTitleBar, so applyFullWindowContent does run and the title bar does go transparent — but apple.awt.application.appearance was never set, because AWT booted long ago with useNativeTitleBar = false. So the first-time user enabling this feature lands in precisely the unreadable-title state the PR set out to prevent, whenever their system appearance disagrees with their terminal background. They only get the fix on their next cold launch.

b) defaultBackground. ThemeManager.withThemeColors rewrites it live on every theme pick, and solarized-light (0xFFFDF6E3) ships built-in. Dark theme at launch → NSAppearanceNameDarkAqua → switch to Solarized Light → near-white title text on #FDF6E3, until a real relaunch. Same failure, mirrored. TerminalSettingsOverride.defaultBackground is a third path in.

The property genuinely can only be set pre-AWT, so this is not a "just make it reactive" fix — but the gap is worth closing on the UX side. Cheapest option: keep the value read at startup and compare, e.g.

// in the window composition, or wherever settings are observed
val contrastStale = styleApplied &&
    isDarkBackground(windowSettings.defaultBackground) != startupAppearanceWasDark

and route that into the same restart affordance the toggle already has, or at minimum a note under the theme/background picker. Failing that, the useNativeTitleBar restart dialog should say it needs a full app relaunch, not the window recreation it actually performs — right now the dialog promises something the handler does not deliver for this particular setting.

2. Both appearance tests pass vacuously on CI

NativeTitleBarStyleTest opens with a KDoc arguing, correctly, that the platform is injected "so that every assertion runs on every runner; otherwise each machine would skip the half of the contract it cannot see." Then:

@Test
fun `appearance follows the background, not the system`() {
    if (!ShellCustomizationUtils.isMacOS()) return
    ...
}

nativeTitleBarAppearance is the one function here without an injectable isMacOS, so on Linux CI this test returns before asserting anything, and the custom title bar leaves the system appearance alone asserts null for the wrong reason — the platform check, not the useNativeTitleBar = false branch. The dark/light mapping — the thing that decides whether every window ships a legible title — is currently unexercised on the runners that actually run.

Fix is one parameter, matching the neighbour:

fun nativeTitleBarAppearance(
    useNativeTitleBar: Boolean,
    backgroundHex: String,
    isMacOS: Boolean = ShellCustomizationUtils.isMacOS(),
): String? { ... }

then drop the guard and add an isMacOS = false case.

3. System.setProperty clobbers an explicit -Dapple.awt.application.appearance

Main.kt:90-93 writes unconditionally, overriding anything set on the command line, in gradle.properties, or in the jpackage launcher config. Consider only writing when the property is absent, so an operator retains an escape hatch:

if (System.getProperty("apple.awt.application.appearance") == null) { ... }

4. The luminance is weighted, not averaged test does not test weighting

assertTrue(isDarkBackground("0xFF0000FF"), "saturated blue reads as dark")
assertFalse(isDarkBackground("0xFF00FF00"), "saturated green reads as light")

Blue is dark under both formulas (plain average 85 < 128, weighted 18 < 128), so it cannot distinguish them — and the comment half-notices this and ends up garbled ("avg 85 vs the 128 threshold is dark, but 0,0,255 against a naive max/mid test is not"). Only the green case discriminates. A colour where the two formulas actually disagree in the other direction would round it out: magenta 0xFFFF00FF averages 170 (→ "light") but weighs 72.6 (→ dark). Swapping blue for magenta makes the test say what its name claims.

5. Comment in TabController.onPromptStarted overstates the isolation

// 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 = ""

The display.windowTitle setter is _windowTitle.value = value ?: "" (ComposeTerminalDisplay.kt:272-276), and EmbeddableTerminal.kt:316 collects windowTitleFlow — so the empty does reach that public callback's flow. It is harmless only because that collector happens to guard on if (title.isNotEmpty()). Worth rewording so the next reader knows the safety rests on a guard in a different file rather than on the write not propagating — and that guard is now load-bearing for this feature with nothing pinning it down. SessionHost.kt:219 and DaemonShareServer.kt:1298 use the same ifBlank { … } shape on the daemon cores, which do not go through wireCwdTitle, so those are unaffected — good.

6. NotificationTitleProvider's threading KDoc covers half the crossing

The AtomicReference reasoning for the tab slot is sound. But invoke() then reads tab.title.value and tab.customTitle.value — Compose MutableState, resolved against the global snapshot — from the terminal reader thread, and that read has no such edge; it sees whatever the last applied snapshot holds. In practice fine (the global snapshot advances per frame, and onPromptStarted already writes session.title.value from that same thread today, so this is a pre-existing pattern rather than something the PR introduces). Just noting the KDoc reads as if the atomic closes the whole gap.


Smaller notes

  • LaunchedEffect(fullWindowContent) { if (fullWindowContent) … } never assigns styleApplied = false on the other branch. Unreachable today — both values come from keyless remembers — but an else arm would make it robust to that changing.
  • NATIVE_TITLE_BAR_HEIGHT = 28.dp: worth confirming the dp↔point identity holds on a non-Retina external display, since Compose Desktop derives density from the GraphicsConfiguration scale and the traffic lights are laid out in points. Manual testing on a 1x monitor would settle it.
  • macOS reveals the title bar on hover at the top edge in native fullscreen, so the traffic lights will briefly overlay the tab bar there (inset is 0). Cosmetic and probably not worth chasing, but it is a consequence of the Fullscreen gate rather than something the gate avoids.
  • Not tested, and arguably the highest-risk untested behaviour: the OSC 2 clear in onPromptStarted. WindowTitleResolutionTest covers resolveWindowTitle thoroughly, but nothing asserts that display.windowTitle is actually emptied on onPromptStarted — which is the half that makes the fallback fire. A small test around the wireCwdTitle CommandStateListener would lock in the "vim stops naming the window after it exits" behaviour the PR is selling.
  • No security concerns; no new I/O, no new external surface. Performance is fine — distinctUntilChanged before the AppKit setTitle is the right guard, and the title chain is O(1) per emission.

Nothing here blocks the visual change, which is the bulk of the PR. Item 1 is the one I would want addressed before merge, since it means the feature's own contrast fix does not reach the user who enables the feature; item 2 is a quick fix that makes the appearance logic actually covered.

@kshivang
kshivang merged commit 49aac64 into master Aug 9, 2026
5 checks passed
@kshivang
kshivang deleted the feat/full-window-content branch August 9, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant