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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 🎨 Design

- **macOS**: Moved the terminal tabs into the window titlebar (Chrome-style) instead of a separate strip above the terminal. The tabs now fill the previously-empty titlebar space next to the toolbar icons, reclaiming a full row of vertical height for the terminal.
- **macOS**: Reworked the sidebar worktree row to read like a native list instead of a dense terminal node. The leading dot is now a glyph that fuses identity and agent state — a branch glyph for the main worktree, a node graph for linked worktrees, tinted by state and pulsing while an agent waits on you. Branch names switched from monospace to a proportional 13pt, and the selected row is a solid accent fill with white text instead of a faint gradient, so "you are here" reads at a glance.
- **macOS**: Flattened the sidebar to two levels by default. tmux windows (the third level) are now collapsed behind a `N ›` chip on the worktree row and only expand on demand — single-window worktrees show no chip since selecting the worktree already lands on its window. The project header was demoted to a quiet group label (smaller tile, muted name, no competing selection highlight) with a status pip only when something inside needs you. When a hidden window is waiting or errored, a small dot appears on the chip instead of forcing the level open.
- **macOS**: On the collapsed sidebar rail, a project whose agent is waiting on you now pulses its ring (mirroring the expanded glyph), so the dock actively draws your eye to whoever needs input.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### 🎨 界面优化

- **macOS**:把终端标签页移进窗口标题栏(Chrome 风格),不再单独占用终端上方的一条。标签页现在填进标题栏里工具栏图标旁那块原本空着的区域,为终端腾回一整行的纵向高度。
- **macOS**:重做侧栏 worktree 行,让它像原生列表而非密集的终端节点。左侧小圆点改为承载身份与 agent 状态的 glyph——主 worktree 用分支图标、linked worktree 用节点图,按状态着色,agent 等待输入时脉冲闪动。分支名从等宽改为比例 13pt,选中行从淡渐变改为实心强调色 + 白字,「当前位置」一眼可辨。
- **macOS**:侧栏默认收为两级。tmux 窗口(第三级)现在收进 worktree 行尾的 `N ›` chip,点击才展开——单窗口的 worktree 不显示 chip,因为选中该 worktree 本就直达其窗口。项目头降级为安静的分组标签(更小的字母块、灰化名称、去掉抢眼的选中高亮),仅当其内有事需要你处理时才显示状态圆点。被收起的窗口处于等待/报错时,chip 上会出现一个小圆点,而非强行撑开该层级。
- **macOS**:收起态侧栏(rail)中,有 agent 等待你输入的项目,其外环现在会呼吸闪动(与展开态的 glyph 一致),让 dock 主动把视线引向需要处理的项目。
Expand Down
3 changes: 2 additions & 1 deletion Sources/Mori/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.sidebarController = sidebarController
sidebarController.updateAppearance(themeInfo: themeInfo)

terminalArea.configureTabs(
let tabsView = TerminalTabsBarView(
appState: state,
onSelectWindow: { [weak manager, weak self] windowId in
manager?.selectWindow(windowId)
Expand All @@ -285,6 +285,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
)
windowController.installTabsView(tabsView)

let splitVC = RootSplitViewController(
sidebarController: sidebarController,
Expand Down
86 changes: 85 additions & 1 deletion Sources/Mori/App/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ final class MainWindowController: NSWindowController {
static let git = NSToolbarItem.Identifier("openGit")
static let splitRight = NSToolbarItem.Identifier("splitRight")
static let splitDown = NSToolbarItem.Identifier("splitDown")
static let tabsLeadingSpacer = NSToolbarItem.Identifier("terminalTabsLeadingSpacer")
static let tabs = NSToolbarItem.Identifier("terminalTabs")
}

private struct ToolbarItemDef {
Expand Down Expand Up @@ -75,6 +77,13 @@ final class MainWindowController: NSWindowController {
/// The hosting view for the update pill, overlaid on the titlebar.
private var updateOverlay: NSView?

/// Native AppKit terminal tabs strip, shown as a toolbar item in the titlebar.
/// Created by `installTabsView(_:)` once AppState/callbacks are available; the
/// toolbar is built afterwards so the item has a real size.
private var tabsView: TerminalTabsBarView?
private var tabsLeftMargin: CGFloat?
private var rightToolbarTrailingInset: CGFloat?

// MARK: - Shortcut Hints

private let shortcutHintMonitor = ShortcutHintModifierMonitor()
Expand Down Expand Up @@ -102,7 +111,6 @@ final class MainWindowController: NSWindowController {
super.init(window: window)

window.delegate = self
configureToolbar()
startShortcutHintMonitor()
}

Expand Down Expand Up @@ -132,6 +140,16 @@ final class MainWindowController: NSWindowController {
onShowCreateWorktreePanel?()
}

/// Install the terminal tabs strip into the titlebar and build the toolbar.
/// The tabs view reports an intrinsic content size that tracks the tab count,
/// so the toolbar item grows and shrinks with the tabs. The toolbar is
/// configured here (not in init) so the item already has a real size when
/// AppKit first measures it — otherwise it gets shoved into the overflow menu.
func installTabsView(_ rootView: TerminalTabsBarView) {
tabsView = rootView
configureToolbar()
}

func addUpdateAccessory(viewModel: UpdateViewModel) {
guard let window else { return }
guard let themeFrame = window.contentView?.superview else { return }
Expand Down Expand Up @@ -165,6 +183,43 @@ final class MainWindowController: NSWindowController {
toolbar.showsBaselineSeparator = false
window?.toolbar = toolbar
window?.toolbarStyle = .unifiedCompact
DispatchQueue.main.async { [weak self] in
self?.updateTabsStripWidth()
}
}

private func updateTabsStripWidth() {
guard let window,
let tabsView,
let leftAnchor = visibleToolbarView(for: ToolbarID.agentDashboard),
let rightAnchor = visibleToolbarView(for: ToolbarID.files) else { return }
window.contentView?.superview?.layoutSubtreeIfNeeded()

let leftMax = leftAnchor.convert(leftAnchor.bounds, to: nil).maxX
let tabsFrame = tabsView.convert(tabsView.bounds, to: nil)
guard tabsFrame.width > 0 else { return }

let measuredLeftMargin = max(0, tabsFrame.minX - leftMax)
if tabsLeftMargin == nil || measuredLeftMargin > 0 {
tabsLeftMargin = measuredLeftMargin
}

if rightAnchor.window === window {
let rightMin = rightAnchor.convert(rightAnchor.bounds, to: nil).minX
if rightMin > leftMax, rightMin < window.frame.width {
rightToolbarTrailingInset = window.frame.width - rightMin
}
}

guard let tabsLeftMargin, let rightToolbarTrailingInset else { return }
let targetRightMin = window.frame.width - rightToolbarTrailingInset
let targetWidth = targetRightMin - tabsFrame.minX - tabsLeftMargin
tabsView.setStripWidth(targetWidth)
}

private func visibleToolbarView(for id: NSToolbarItem.Identifier) -> NSView? {
let visibleItems = window?.toolbar?.visibleItems ?? []
return visibleItems.first(where: { $0.itemIdentifier == id })?.view
}

private static let symbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular)
Expand Down Expand Up @@ -282,6 +337,10 @@ extension MainWindowController: NSWindowDelegate {
func windowDidResignKey(_ notification: Notification) {
onWindowAppearanceInvalidated?()
}

func windowDidResize(_ notification: Notification) {
updateTabsStripWidth()
}
}

// MARK: - NSToolbarDelegate
Expand All @@ -293,6 +352,8 @@ extension MainWindowController: NSToolbarDelegate {
ToolbarID.openProject,
ToolbarID.commandPalette,
ToolbarID.agentDashboard,
ToolbarID.tabsLeadingSpacer,
ToolbarID.tabs,
.flexibleSpace,
ToolbarID.files,
ToolbarID.git,
Expand All @@ -314,6 +375,29 @@ extension MainWindowController: NSToolbarDelegate {
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
if itemIdentifier == ToolbarID.tabsLeadingSpacer {
let item = NSToolbarItem(itemIdentifier: ToolbarID.tabsLeadingSpacer)
let spacer = NSView(frame: NSRect(x: 0, y: 0, width: 14, height: 24))
spacer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
spacer.widthAnchor.constraint(equalToConstant: 14),
spacer.heightAnchor.constraint(equalToConstant: 24),
])
item.view = spacer
return item
}
if itemIdentifier == ToolbarID.tabs {
let item = NSToolbarItem(itemIdentifier: ToolbarID.tabs)
item.label = .localized("Tabs")
item.paletteLabel = .localized("Tabs")
item.visibilityPriority = .high
item.view = tabsView
tabsView?.onIntrinsicContentSizeChanged = { [weak self, weak toolbar] in
toolbar?.validateVisibleItems()
DispatchQueue.main.async { self?.updateTabsStripWidth() }
}
return item
}
guard let def = Self.toolbarItemDefs.first(where: { $0.id == itemIdentifier }) else {
return nil
}
Expand Down
159 changes: 2 additions & 157 deletions Sources/Mori/App/TerminalAreaViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,7 @@ private final class WorkspaceGlassBackgroundView: NSView {
/// running `tmux new-session -A -s <session-name>` to attach-or-create.
@MainActor
final class TerminalAreaViewController: NSViewController {
private static let tabBarHeight: CGFloat = 34

private var glassBackgroundView: NSView?
private var tabsHostingController: NSHostingController<TerminalTabsBarView>?

// MARK: - Dependencies

Expand Down Expand Up @@ -139,7 +136,6 @@ final class TerminalAreaViewController: NSViewController {
let container = NSView()
container.wantsLayer = true
self.view = container
installTabsViewIfNeeded()
updateAppearance(themeInfo: themeInfo, isKeyWindow: true)
showEmptyState()
}
Expand All @@ -152,25 +148,6 @@ final class TerminalAreaViewController: NSViewController {
}
}

func configureTabs(
appState: AppState,
onSelectWindow: @escaping (String) -> Void,
onCloseWindow: @escaping (String) -> Void,
onCreateWindow: @escaping () -> Void
) {
let tabsView = TerminalTabsBarView(
appState: appState,
onSelectWindow: onSelectWindow,
onCloseWindow: onCloseWindow,
onCreateWindow: onCreateWindow
)
let controller = NSHostingController(rootView: tabsView)
controller.sizingOptions = []
tabsHostingController = controller
addChild(controller)
installTabsViewIfNeeded()
}

func updateAppearance(themeInfo: GhosttyThemeInfo, isKeyWindow: Bool) {
view.layer?.backgroundColor = backgroundColor(for: themeInfo).cgColor
updateGlassEffectIfNeeded(themeInfo: themeInfo, isKeyWindow: isKeyWindow)
Expand Down Expand Up @@ -410,26 +387,7 @@ final class TerminalAreaViewController: NSViewController {
}

private var terminalContentTopAnchor: NSLayoutYAxisAnchor {
if let tabsView = tabsHostingController?.view, tabsView.superview === view {
return tabsView.bottomAnchor
}
return view.topAnchor
}

private func installTabsViewIfNeeded() {
guard isViewLoaded,
let controller = tabsHostingController,
controller.view.superview == nil else { return }

let tabsView = controller.view
tabsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tabsView)
NSLayoutConstraint.activate([
tabsView.topAnchor.constraint(equalTo: view.topAnchor),
tabsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tabsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tabsView.heightAnchor.constraint(equalToConstant: Self.tabBarHeight),
])
view.topAnchor
}

@objc private func emptyStateButtonClicked() {
Expand Down Expand Up @@ -536,8 +494,7 @@ final class TerminalAreaViewController: NSViewController {

/// Defensive cleanup in case a dead surface view was not tracked as current.
private func removeResidualTerminalSubviews() {
let tabsView = tabsHostingController?.view
for subview in view.subviews where subview !== emptyStateView && subview !== tabsView {
for subview in view.subviews where subview !== emptyStateView {
if subview !== currentSurface {
subview.removeFromSuperview()
}
Expand Down Expand Up @@ -620,115 +577,3 @@ final class TerminalAreaViewController: NSViewController {
}
}
}

private struct TerminalTabsBarView: View {
@Bindable var appState: AppState
let onSelectWindow: (String) -> Void
let onCloseWindow: (String) -> Void
let onCreateWindow: () -> Void

private var windows: [RuntimeWindow] {
appState.windowsForSelectedWorktree
}

var body: some View {
HStack(alignment: .bottom, spacing: 0) {
ForEach(windows) { window in
terminalTab(for: window)
}

Button(action: onCreateWindow) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .semibold))
.frame(width: 28, height: 26)
.foregroundStyle(MoriTokens.Color.muted)
.background(Color.primary.opacity(MoriTokens.Opacity.quiet))
.clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small))
}
.buttonStyle(.plain)
.help(String.localized("New Tab"))
.accessibilityLabel(String.localized("New Tab"))
.padding(.leading, MoriTokens.Spacing.sm)
.padding(.bottom, 5)

Spacer(minLength: 0)
}
.padding(.leading, MoriTokens.Spacing.xl)
.padding(.trailing, MoriTokens.Spacing.lg)
.background(Color.clear)
}

private func terminalTab(for window: RuntimeWindow) -> some View {
let isSelected = window.tmuxWindowId == appState.uiState.selectedWindowId

return HStack(spacing: MoriTokens.Spacing.sm) {
Circle()
.fill(tabDotColor(for: window, isSelected: isSelected))
.frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot)

Text(tabTitle(for: window))
.font(.system(size: 13, weight: isSelected ? .semibold : .medium))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(isSelected ? Color.primary : MoriTokens.Color.muted)
.frame(maxWidth: .infinity, alignment: .leading)

if isSelected {
Button(action: { onCloseWindow(window.tmuxWindowId) }) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(MoriTokens.Color.muted)
.frame(width: 16, height: 16)
.background(Color.primary.opacity(MoriTokens.Opacity.quiet))
.clipShape(Circle())
}
.buttonStyle(.plain)
.help(String.localized("Close Tab"))
.accessibilityLabel(String.localized("Close Tab"))
}
}
.padding(.horizontal, MoriTokens.Spacing.lg)
.frame(width: 170, height: 32)
.contentShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous))
.onTapGesture {
onSelectWindow(window.tmuxWindowId)
}
.background(
RoundedRectangle(
cornerRadius: MoriTokens.Radius.medium,
style: .continuous
)
.fill(isSelected ? Color.primary.opacity(0.11) : Color.primary.opacity(0.055))
)
.overlay(alignment: .bottom) {
if isSelected {
Rectangle()
.fill((Color(nsColor: .windowBackgroundColor)))
.frame(height: 1)
}
}
.overlay {
RoundedRectangle(cornerRadius: MoriTokens.Radius.medium, style: .continuous)
.strokeBorder(isSelected ? Color.primary.opacity(0.16) : Color.clear, lineWidth: 1)
}
.padding(.top, 2)
}

private func tabTitle(for window: RuntimeWindow) -> String {
if !window.title.isEmpty {
return window.title
}
return String.localized("Window \(window.tmuxWindowIndex)")
}

private func tabDotColor(for window: RuntimeWindow, isSelected: Bool) -> Color {
if isSelected { return MoriTokens.Color.active }
if window.detectedAgent != nil || window.agentState != .none { return MoriTokens.Color.info }
if window.hasUnreadOutput { return MoriTokens.Color.attention }
switch window.tag {
case .server: return MoriTokens.Color.success
case .agent: return MoriTokens.Color.info
default: return MoriTokens.Color.inactive
}
}
}
Loading
Loading