Skip to content

Two rows tab bar - #692

Open
bcollard wants to merge 2 commits into
gnachman:masterfrom
bcollard:ui/two-lines-tabs-bar
Open

Two rows tab bar#692
bcollard wants to merge 2 commits into
gnachman:masterfrom
bcollard:ui/two-lines-tabs-bar

Conversation

@bcollard

Copy link
Copy Markdown

With the advent of agentic coding tools, people tend to have more open tabs at the same time.
This PR introduces a new advanced setting to enable a dual-row tab bar.

It's a no-op if users choose to have the tabs in a sidebar.

@gnachman

gnachman commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for tackling something hard. It's clear you spent real time in the layout engine: moving the bar out of the titlebar accessory into the content view when it grows past the standard titlebar height, deferring the height recompute to avoid reentrant layout, and handling Tahoe's y-reset in clippingPath are all things you only discover by running it and watching it break. The comments explaining why are excellent, and the housekeeping (advanced setting, release notes, conventions) is all in order.

My main concern is scope of testing rather than craft. A global setting that reshapes tab layout has a large test surface (themes x macOS version x pinned/overflow/add-button x feature on/off), and right now the implementation looks validated mainly on the Tahoe style on macOS 26 with a few plain tabs. Most of the issues below cluster in the configurations that path never exercises. Before this is ready I'd want to see it driven through each theme (Regular/Tahoe, Compact, Minimal), on macOS < 26 as well as 26, and with pinned tabs and the add-tab button visible.

Specific findings, most important first:

  1. Spurious full refresh on every window, even with the feature off. _lastLaidOutHorizontalRowCount initializes to 0 while horizontalRowCount returns 1 in the normal case, so the first reallyUpdate: of every window sees 1 != 0 and dispatches tabViewDidChangeDesiredHeight: to refreshTerminal:. That's a full window-chrome rebuild at open for 100% of users regardless of the setting. Seed the ivar to 1 or gate the notification on the feature being enabled.

  2. Add-tab "+" button lands off-screen on the non-Tahoe style. PSMYosemiteTabStyle.frameForAddTabButtonWithCellWidths: positions the button at leftMargin + sum(all widths), but in two-row mode that array holds both rows' widths, so the sum is roughly 2x a single row and the button ends up past the right edge. This is the default style on macOS < 26.

  3. Pinned tabs lose their fixed width. The new two-row branch in cellWidthsForHorizontalArrangementWithOverflow: returns before the pinned-cell handling, so pinned tabs get stretched to the same even width as everything else instead of _pinnedTabWidth, and can even land on the second row.

  4. Only the Tahoe style got the rendering updates. Enablement is global (the setting plus _desiredTabBarHeight multiplying by the row count for every theme), but only PSMTahoeTabStyle got the row-aware tabBarHeight and clippingPath. On non-Tahoe themes the bar grows and cells are placed on two rows, but separators and background still draw single-row geometry. This logic probably wants to live at the shared layer rather than in one style.

  5. One-frame squish when crossing into two rows. In _setupCells:, rowHeight = generic.size.height / 2 uses the current (still single-row) height because the height increase is dispatched async, so the pass that adds the overflowing tab draws both rows at half height until the async refresh grows the bar.

  6. horizontalRowCount returns 2 for a zero/invalid frame width. During early setup frame.width can be ~0, which makes capacity clamp to 1 and the method return 2 with 2+ cells, briefly forcing double height and content-view placement before it corrects. The tabBarHeight comment says it defaults to one row when not wired up, but it actually returns 2.

  7. The iTermAdvancedSettingsDidChange observer is too broad. That notification fires for any advanced setting (one KVO block per key), so every window now runs a full refreshTerminal: when any unrelated advanced setting changes. Worth filtering to the tab-bar keys or debouncing.

  8. availableCellWidthWithOverflow: is overloaded to return 2x the real width in two-row mode, which already forced a patch in genericCellRectWithOverflow:. A future caller expecting a real pixel width will be off by 2x only when the setting is on. A dedicated "layout container width" method would be safer than overloading the physical-width accessor.

The core idea and the mechanism are solid, and the hard parts are done well. It mostly needs the config matrix widened and the theme-specific pieces generalized. Happy to re-review once the other themes and the feature-off path are exercised.

@bcollard
bcollard force-pushed the ui/two-lines-tabs-bar branch 2 times, most recently from abf5517 to 060be90 Compare July 12, 2026 16:16
@bcollard

Copy link
Copy Markdown
Author

Thanks for taking the time to provide such thorough feedback!
I've addressed all the requested changes.
Please take a look when time allows.

Here are two screenshots with the default theme and with the Minimal UI:
image
image

@gnachman

Copy link
Copy Markdown
Owner

Thanks for the rework. This round is a real step up. I re-verified every point from my first review and they are all genuinely addressed:

  1. Feature-off spurious refresh: fixed (ivar seeded to 1 in init and the tabViewDidChangeDesiredHeight: dispatch is gated on twoRowTabBar).
  2. Add-tab "+" off-screen on Yosemite: fixed (the add-button is now fed only the row-1 widths).
  3. Pinned tabs stretched/misplaced: fixed (pinned cells stay at _pinnedTabWidth on row 1; only unpinned cells split).
  4. Rendering only on Tahoe: mostly fixed. The shared geometry and the per-cell isFirstInHorizontalRow/isLastInHorizontalRow flags now live in PSMTabBarControl, and Minimal/Yosemite consume them. See the Tahoe exception below though.
  5. One-frame half-height squish: fixed (the delegate height is adopted synchronously before cell layout).
  6. horizontalRowCount returning 2 at ~0 width: fixed.
  7. Blanket advanced-settings observer: fixed (handler now early-returns unless the desired bar height actually changed).
  8. availableCellWidthWithOverflow: 2x overload: fixed (dedicated singleRowAvailableCellWidthWithOverflow:).

Vertical/sidebar tab bars are handled well and documented as a no-op (horizontalRowCount returns 1 for non-horizontal orientation, every new path is gated on it, and the setting description says it only affects top/bottom bars). No concerns there.

Then I drove it through the wider theme/config matrix, and the non-default configurations still have problems. The geometry is two-row aware now, but several per-cell drawing and interaction paths still assume a single left-to-right row. Concrete bugs:

1. Minimal: the inter-row divider draws over the overflow button

PSMMinimalTabStyle.m drawPlainHorizontalOutlineAtY: strokes the divider from x = 0 to NSMaxX(bar.frame), the full bar width, so when the overflow chevron is visible the divider runs straight through it. The selected-tab notch primitives just above it already stop at NSMaxX(bar.frame) - [self rightMarginForTabBarControlWithOverflow:...]. Have the divider use that same right margin when !bar.overflowPopUpButton.isHidden. Repro: Minimal theme, enough tabs to show the overflow chevron with two rows.

2. Compact: row-1 tabs overlap the window shortcut, and row 2's left is a dead zone

In Compact the first row has to hold the traffic lights and the window-shortcut label. Two problems compound:

  • Row 1 does not reserve the shortcut: the window-shortcut label is positioned by the window chrome for the single-row bar geometry, so when the bar doubles in height the first tab draws over the shortcut.
  • Row 2 reclaims the left inset to x = 0 (lowerRowOriginX = generic.origin.x - insets.left), but cellForPoint: still early-outs on genericCellRectWithOverflow whose origin.x = insets.left. So the leftmost insets.left points of row 2's first tab (about 75pt under the traffic lights in Compact) are unclickable: no select, no close button, no drag, no tooltip.

Compact needs the left-chrome (traffic lights plus window shortcut) reservation and hit-testing to be made two-row aware. This is not a one-liner like the others.

3. Tahoe: spurious divider to the right of the last cell on the bottom row

4. Tahoe: hovering the rightmost cell on row 2 removes a divider on row 1

Both are the same root cause. In PSMTahoeTabStyle.swift the inter-cell dividers are computed by sorting all cells by frame.minX only, ignoring the row, then drawing a divider between each consecutive pair:

let sorted = cellsForDividers.sorted { lhs, rhs in
    lhs.frame.minX < rhs.frame.minX     // X only; the two rows get interleaved
}
for i in 0..<(sorted.count - 1) {
    drawDivider(betweenCell: sorted[i], andCell: sorted[i + 1])
}

So "adjacent" means adjacent-in-X across both rows. The rightmost row-2 cell's X-neighbor is a row-1 cell, which is why (3) draws a divider just past the last bottom-row cell and (4) suppresses a row-1 divider when you hover the row-2 cell (drawDivider skips when leftCell.isHighlighted || rightCell.isHighlighted). Unlike Minimal/Yosemite, Tahoe's divider pass was not switched to the new row flags. Fix: only draw a divider between cells on the same physical row (sort by (minY, minX) and skip pairs whose minY differs, or skip when isLastInHorizontalRow).

Other findings from code review (not yet visible in casual testing)

  • Drag and drop insertion index is wrong across rows. PSMTabDragAssistant computes the drop target with if (mouseLoc.x < leftMargin) target = cells[0] and picks neighbors by cells[index +/- 1]. In two rows, dropping over the left of row 2 (x < insets.left) forces insertion at the front of row 1, and crossing the row boundary picks a cell on the other physical row.
  • Add-tab "+" placement in two-row Yosemite/Minimal: row-1 cells are stretched to fill row1Width, so insets.left + sum(row1Widths) puts the "+" flush at the right edge with no gap after the last tab, while row 2 reserves the right margin for a "+"/overflow it does not host, leaving dead trailing space. The "+" probably belongs at the end of row 2 (which has the reserved room).
  • Stale row-boundary flags during the width animation, on the feature-OFF path. The flags are cached only in finishUpdateWithRegularWidths, which the animated layout path returns before reaching, so during the add/remove-tab animation drawTabCell reads stale isFirst/isLastInHorizontalRow and the end separators can flash on the wrong tab. The pre-PR code computed first/last live every draw. This is a small regression to the default single-row experience.
  • Tahoe two-row track height is hardcoded to barHeight (28) instead of twoRowContentHeight(), while the row stride is derived from insets. In traditional fullscreen or compact where per-row height is not 28, the two capsule tracks gap or overlap and no longer align with the cell rows.
  • The single-vs-two-row decision estimates capacity from cellMinWidth (100pt), not the widths a single row would actually use. So the 1 to 2 decision can disagree with what one row would really show. This is also why "open a bunch of tabs" may not produce a second row on a wide window until you cross the 100pt/tab estimate.
  • Crossing the capacity boundary during a live window resize fires a full refreshTerminal: (the heavy window-chrome rebuild), which can jank a drag-resize that hovers at the threshold.

One design question

The feature reads as "two rows when enabled" but behaves as "spill to two rows only on overflow." On a wide window that means many tabs before anything changes. Worth deciding whether the setting should mean always-two-rows-when-enabled, and clarifying the description either way.

Net: mechanism and the hard parts are solid, and the round-1 issues are all fixed. But Compact is not usable yet (overlap plus the hit-test dead zone), Minimal has the overflow-divider bug, and Tahoe has the two divider bugs, so I would like another pass focused on the per-cell drawing/interaction paths in Compact/Minimal/Tahoe with the overflow chevron present. Happy to re-review after that.

bcollard and others added 2 commits August 5, 2026 08:54
Add an advanced setting, "Show tab bar on two rows" (TwoRowTabBar, off by
default), that distributes horizontal tab-bar tabs across two rows once they
would overflow a single row, giving each tab more room for its title. Only
affects top/bottom tab bars; left/right (vertical) bars are unchanged, and the
whole feature is a no-op when the setting is off.

Layout: PSMTabBarControl owns the shared two-row geometry — row-count detection
(horizontalRowCount), per-row width distribution (including pinned tabs and the
add-tab button), and the vertical model laid out as [top][row][gap][row][bottom]
(twoRowHeightForSingleRowHeight:/twoRowContentHeight/twoRowStride, one source of
truth for both the style height and the window's desired height). Per-row
first/last flags are cached on the cell once per layout so styles read them O(1).

Styles: PSMTahoeTabStyle draws one rounded track per row with the selected pill
inset; PSMYosemiteTabStyle draws per-row separators; PSMMinimalTabStyle draws a
per-row notch outline for both top and bottom tab positions.

Window chrome: a two-row bar exceeds the titlebar-accessory height, so it moves
into the content view; the window number and title labels stay centered on the
first row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second round of review fixes. All of these are about paths that still
assumed a single left-to-right row after the geometry became two-row aware.

Tahoe: the inter-cell dividers were computed by sorting cells on frame.minX
alone, which interleaves the two rows, so the rightmost cell of one row was
treated as the X-neighbor of a cell on the other. That drew a spurious
divider just past the last cell of the upper row, and hovering the lower
row's rightmost cell suppressed a divider up on the upper row. Sort by
(minY, minX) and only draw between cells that share a row. Compared
geometrically rather than via the cached isFirst/isLastInHorizontalRow flags
because the animated layout path doesn't refresh those.

Tahoe: the two-row background/clip tracks used the hardcoded single-row
barHeight (28) while the stride came from the shared geometry, so the tracks
gapped or overlapped, and stopped aligning with the cell rows, wherever the
per-row height isn't 28pt. Take the height from twoRowContentHeight too.

Minimal: the line between the two rows was stroked to NSMaxX(bar.frame),
running straight through the overflow chevron (which spans the whole bar
height). The inter-row line now stops at the right margin when the chevron
is visible; the bar's outer edge against the content still spans full width,
since it passes below the chevron rather than across it. The selected-tab
notch on the inter-row boundary gets the same treatment via a rightEdge:
parameter.

Hit testing: cellForPoint: early-returned on the generic cell rect, which
neither row fits inside — the lower row starts at the reclaimed left edge
and the pair of rows extends to the smaller two-row bottom inset. In compact
that left roughly 75pt of the lower row's first tab (under the traffic
lights) dead to clicks, drags, close buttons, and tooltips. Added
cellHitTestBoundsWithOverflow: for the region the cells really occupy.

Drag and drop: "left of the first tab" was measured against the style's left
margin, so dropping anywhere in the lower row's reclaimed inset inserted at
the very front of the upper row. It now measures against the left edge of
the row under the mouse (leftEdgeOfHorizontalCellRowAtY:). The drag reflow
also advanced its x cursor across every cell in sequence, which pushed the
lower row's tabs off the right edge; it now restarts at each row's own left
edge. Both gated so the single-row path is untouched.

Add-tab button: moved from the top row to the bottom row. Both rows stretch
to the same reserved right margin, so on the top row the + sat in space the
top row's own tabs already reach while the bottom row's reserved slot went
unused.

Row-boundary flags: only finishUpdateWithRegularWidths: refreshed them, and
the animated width path returns before reaching it, so the add/remove-tab
animation drew with flags from the previous cell set and the end separators
could land on the wrong tab. This was a regression to the default,
feature-off single-row experience. Refresh them when the animation starts.

Row-count decision: horizontalRowCount estimated capacity as cellMinWidth
per cell, which ignores pinned tabs' fixed width and could disagree with
what one row would actually show. It now asks the same minimum-width fit
question the single-row layout asks itself.

Resize cost: a row-count change only needs the full refreshTerminal: window-
chrome rebuild when it also flips whether the bar is a titlebar accessory.
When the bar isn't moving — always the case for Minimal and Compact, which
are never accessories — a reposition plus tab refit is enough, so
drag-resizing across the row-count boundary no longer rebuilds the chrome.

Build fix: declare -update: in PSMTabBarControl.h. The first round added
[self update:NO] calls to iTermTabBarControlView's updateHeightWithDefault:,
but -update: was only declared in PSMTabBarControlProtocol, and
PSMTabBarControl neither lists that protocol in its interface nor imports the
header declaring it, so the selector was invisible to the subclass. It only
compiled against stale DerivedData; a clean build on current master fails
with -Wobjc-method-access.

Compact: two-row height now applies to every theme, not just Tahoe. On macOS 26
iTermTabBarControlView's setOrientation: sets the bar height from the style's
tabBarHeight, and setTabLocation: calls it on every layout pass -- so it ran
after updateHeightWithDefault: had set the correct two-row height and shrank the
bar straight back to one row's worth. Tahoe happened to be immune because its
tabBarHeight returned the doubled value itself, and Minimal because its
tabBarHeight returns 0 and so fell through to the already-row-aware delegate.
PSMYosemiteTabStyle returns a plain defaultTabBarHeight, which is what Compact
uses, so Compact crammed both rows into a 24pt bar: each row ended up ~12pt,
shorter than the traffic-light cluster, which is anchored to the top and
therefore spilled into the lower row -- where the reclaimed left inset put a tab
directly underneath it. That is the overlap reported in review.

The two-row growth now happens once, where the style's per-row height is
consumed (-heightForRowsGivenSingleRowHeight:), and PSMTahoeTabStyle.tabBarHeight
reports only its single-row height like every other style. Verified on screen in
Compact (overlap gone), and Minimal and Tahoe are unchanged in both one- and
two-row states.

Clicking a tab on the lower row: -wantsMouseDownAtPoint: rejected any point left of
insets.left, and iTermTabBarControlView's -mouseDown: uses that to decide between
selecting a tab and dragging the window. The lower row starts at 0, so in Compact
(insets.left is ~171pt there for the traffic lights and window shortcut) its whole
leading tab fell inside that strip and every click on it became
performWindowDragWithEvent: -- the tab could never be selected, while hover still
worked, because tracking rects are geometric, so its close button appeared on a tab
that would not respond to clicks. Both edges are now row-aware: the left via
-insetAdjustedLeftEdgeOfHorizontalRowAtY: (kept in insets.left terms so single-row
behavior is bit-identical) and the right via -lastVisibleCellInHorizontalRowAtY:,
since the two rows need not end at the same x. Keyboard selection was never
affected, which is what isolated this to the mouse-down path. Fixing -cellForPoint:
alone was necessary but not sufficient.

Setting description and release note reworded to say what the feature
actually does: a second row appears only once tabs would overflow one row,
so a wide window may need many tabs before anything changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bcollard
bcollard force-pushed the ui/two-lines-tabs-bar branch from 060be90 to cf53b38 Compare August 5, 2026 13:56
@bcollard

bcollard commented Aug 5, 2026

Copy link
Copy Markdown
Author

ok, all four bugs fixed. Also, 5 out of 6 bullet points were addressed.
On the design question, yes, the idea is "spill to two rows only on overflow". I've fixed the wording.
One caveat is that I had to rebuild the dependencies using Xcode 26.6. So I could not test with Xcode v26.5

@maskshell

Copy link
Copy Markdown
Contributor

In fact, for current AI Coding Agent scenarios with multiple tabs, I still recommend placing them on the left. Placing them at the top makes the tab names unreadable.

b61f7a6d-b020-4c92-a9aa-a18d2f548a1f

@bcollard

bcollard commented Aug 9, 2026

Copy link
Copy Markdown
Author

In fact, for current AI Coding Agent scenarios with multiple tabs, I still recommend placing them on the left. Placing them at the top makes the tab names unreadable.

Yes, definitely a good recommendation, but you know when it comes to user preferences...
I personally have two external monitors, very wide, 34" each, and I pinned iTerm on one of them with 50% width. I want to use that width for the terminals themselves, not shared with a tab sidebar. And the screen and iTerm windows are high enough that having two rows of tabs is not an issue at all.

@maskshell

Copy link
Copy Markdown
Contributor

you are right

@gnachman

Copy link
Copy Markdown
Owner

Now that tabs scroll on overflow, is this still useful?

@bcollard

Copy link
Copy Markdown
Author

sorry @gnachman I'm not sure I got you here. Are you talking about the horizontal tab scroll?

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.

3 participants