# Workspaces, Surfaces & Panels — How the Screen Is Organized

> A workspace is one tab (row in the sidebar). Each workspace can be split into multiple surfaces (terminal panes). Panels are extra panes — browser, file preview, markdown viewer — that live alongside terminals. This page traces how TabManager creates, tracks, and switches between them.

- Repository: manaflow-ai/cmux
- GitHub: https://github.com/manaflow-ai/cmux
- Human wiki: https://grok-wiki.com/public/wiki/manaflow-ai-cmux-e789cf316a9a
- Complete Markdown: https://grok-wiki.com/public/wiki/manaflow-ai-cmux-e789cf316a9a/llms-full.txt

## Source Files

- `Sources/Workspace.swift`
- `Sources/TabManager.swift`
- `Sources/ContentView.swift`
- `Sources/Panels/BrowserPanel.swift`
- `Sources/Panels/TerminalPanel.swift`
- `Sources/WorkspaceContentView.swift`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [Sources/Workspace.swift](Sources/Workspace.swift)
- [Sources/TabManager.swift](Sources/TabManager.swift)
- [Sources/WorkspaceContentView.swift](Sources/WorkspaceContentView.swift)
- [Sources/Panels/Panel.swift](Sources/Panels/Panel.swift)
- [Sources/Panels/TerminalPanel.swift](Sources/Panels/TerminalPanel.swift)
- [Sources/Panels/BrowserPanel.swift](Sources/Panels/BrowserPanel.swift)
- [Sources/Panels/MarkdownPanel.swift](Sources/Panels/MarkdownPanel.swift)
- [Sources/Panels/FilePreviewPanel.swift](Sources/Panels/FilePreviewPanel.swift)
- [Sources/RightSidebarToolPanel.swift](Sources/RightSidebarToolPanel.swift)
</details>

# Workspaces, Surfaces & Panels — How the Screen Is Organized

cmux organizes your screen into three nested layers: **workspaces** (entries in the left sidebar), **panes** (split regions within a workspace), and **panels** (the actual content living inside each pane). Understanding this stack is the key to making sense of how cmux creates, tracks, and switches between terminal sessions, browser views, and file previews.

This page traces the full hierarchy from the top-level `TabManager` that owns all workspaces, down to the individual `Panel` protocol that every piece of content (terminal, browser, markdown viewer, file preview) conforms to, and explains the glue layer — the Bonsplit library — that manages how panes are split and resized.

---

## The Three-Layer Model

```text
┌─────────────────────────────────────────────────────────────┐
│  TabManager                                                 │
│  ┌────────────────────┐  ┌────────────────────┐             │
│  │  Workspace (tab 1) │  │  Workspace (tab 2) │  ...       │
│  │  ┌─────┬─────────┐ │  │  ┌───────────────┐ │            │
│  │  │Pane │  Pane   │ │  │  │     Pane      │ │            │
│  │  │  ┌──┤  ┌────┐ │ │  │  │  ┌─────────┐ │ │            │
│  │  │  │T │  │ B  │ │ │  │  │  │Terminal │ │ │            │
│  │  │  └──┘  └────┘ │ │  │  │  └─────────┘ │ │            │
│  │  └─────┴─────────┘ │  │  └───────────────┘ │            │
│  └────────────────────┘  └────────────────────┘            │
│   T = TerminalPanel, B = BrowserPanel                       │
└─────────────────────────────────────────────────────────────┘
```

---

## TabManager — The Workspace Registry

`TabManager` is the single `ObservableObject` that owns the ordered list of all open workspaces. Every window gets its own `TabManager` instance.

```swift
// Sources/TabManager.swift:899,1106
class TabManager: ObservableObject {
    @Published var tabs: [Workspace] = []
    @Published var selectedTabId: UUID?
    ...
}
```

The active workspace is tracked via `selectedTabId`. A convenience computed property wraps the lookup:

```swift
// Sources/TabManager.swift:2277-2283
var selectedWorkspace: Workspace? {
    guard let selectedTabId else { return nil }
    return tabs.first(where: { $0.id == selectedTabId })
}
var selectedTab: Workspace? { selectedWorkspace }  // legacy alias
```

> **Backward compatibility note:** The type alias `typealias Tab = Workspace` exists at the top of `TabManager.swift` (line 12) so that call sites written before the rename still compile.

### Creating a Workspace

`addWorkspace(...)` is the canonical entry point. It:
1. Snapshots the current workspace list and working directory.
2. Computes an insertion index based on user preference (`top`, `afterCurrent`, or `end`).
3. Constructs a new `Workspace`, which immediately boots an initial `TerminalPanel`.
4. Inserts the workspace into the `tabs` array.
5. Optionally sets `selectedTabId` to switch to it.

```swift
// Sources/TabManager.swift:2500-2578
func addWorkspace(...) -> Workspace {
    ...
    let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
    let newWorkspace = makeWorkspaceForCreation(...)
    ...
    tabs.insert(newWorkspace, at: insertIndex)
    ...
    selectedTabId = newWorkspace.id   // if select == true
}
```

`addTab(select:eagerLoadTerminal:)` is a thin wrapper that calls `addWorkspace` — it exists for backward-compatible call sites (Sources/TabManager.swift:5132–5133).

### Switching Workspaces

Setting `selectedTabId` triggers a `didSet` observer that:
- Records the previous workspace's focused panel in `lastFocusedPanelByTab` for later restoration.
- Appends to the history stack (`tabHistory`) for back/forward navigation.
- Calls `focusSelectedTabPanel` to transfer macOS first-responder focus.
- Posts a notification so the sidebar highlight can update.

Sources: [Sources/TabManager.swift:1127-1203]()

---

## Workspace — One Sidebar Tab

`Workspace` represents a single entry in the sidebar. It is declared as a `final class` conforming to `Identifiable` and `ObservableObject`.

```swift
// Sources/Workspace.swift:8973-9019
/// Workspace represents a sidebar tab.
/// Each workspace contains one BonsplitController that manages split panes and nested surfaces.
@MainActor
final class Workspace: Identifiable, ObservableObject {
    let id: UUID
    @Published var title: String
    @Published var customTitle: String?
    @Published var isPinned: Bool = false
    @Published var customColor: String?
    @Published var currentDirectory: String
    let bonsplitController: BonsplitController
    @Published var panels: [UUID: any Panel] = [:]
    ...
}
```

Key published properties:

| Property | Purpose |
|---|---|
| `title` | Auto-derived from the active process (e.g., shell prompt) |
| `customTitle` | User-set override shown in the sidebar |
| `customColor` | Hex color string for the sidebar accent dot |
| `isPinned` | Pinned workspaces stay at the top of the list |
| `currentDirectory` | Working directory (drives git metadata watching) |
| `panels` | Map from `UUID` → `any Panel` for all content panes |
| `bonsplitController` | Owns the split-pane layout tree |

### The Bonsplit Bridge

Every `Workspace` owns exactly one `BonsplitController`. Bonsplit is the split-pane engine (imported from the `Bonsplit` package). It manages a binary tree of panes, each holding one or more tabs (surfaces). When bonsplit creates a split, it calls back into `Workspace` via delegate methods like `splitTabBar(_:didSplitPane:newPane:orientation:)`.

The bridge between Bonsplit's internal `TabID` and cmux's `UUID`-based `Panel` system is the `surfaceIdToPanelId` dictionary:

```swift
// Sources/Workspace.swift:9921-9924
// MARK: - Surface ID to Panel ID Mapping
/// Mapping from bonsplit TabID (surface ID) to panel UUID
var surfaceIdToPanelId: [TabID: UUID] = [:]
```

Lookup is bidirectional:
- `panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID?` — used when Bonsplit fires a delegate event
- `surfaceIdFromPanelId(_ panelId: UUID) -> TabID?` — used when cmux needs to tell Bonsplit which tab to select

Sources: [Sources/Workspace.swift:9924](), [Sources/Workspace.swift:10010-10024]()

---

## Panel — Content Inside a Pane

Every piece of content rendered inside a pane — whether a terminal, browser, markdown file, or file preview — conforms to the `Panel` protocol:

```swift
// Sources/Panels/Panel.swift:258-307
@MainActor
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
    var id: UUID { get }
    var panelType: PanelType { get }
    var displayTitle: String { get }
    var displayIcon: String? { get }
    var isDirty: Bool { get }

    func close()
    func focus()
    func unfocus()
    func triggerFlash(reason: WorkspaceAttentionFlashReason)
    func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
    func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool
}
```

### Panel Types

The `PanelType` enum lists every kind of content cmux can place in a pane:

```swift
// Sources/Panels/Panel.swift:6-38
public enum PanelType: String, Codable, Sendable {
    case terminal
    case browser
    case markdown
    case filePreview = "filepreview"
    case rightSidebarTool
}
```

| Panel Type | Class | Description |
|---|---|---|
| `.terminal` | `TerminalPanel` | Wraps a Ghostty terminal surface; the most common panel type |
| `.browser` | `BrowserPanel` | Embedded WKWebView browser with shared cookie pool |
| `.markdown` | `MarkdownPanel` | Live-reloading markdown renderer with text-edit toggle |
| `.filePreview` | `FilePreviewPanel` | PDF, image, media player, or text editor for local files |
| `.rightSidebarTool` | `RightSidebarToolPanel` | File explorer or session index shown as a right-panel tool |

### TerminalPanel — The Core Panel

`TerminalPanel` wraps a `TerminalSurface` (the Ghostty-backed PTY) and adds cmux-layer concerns such as find-in-terminal state, text-box input, and focus management. It explicitly sets `isDirty` to `false` to avoid misleading dirty-dot indicators in the tab strip:

```swift
// Sources/Panels/TerminalPanel.swift:6-17
@MainActor
final class TerminalPanel: Panel, ObservableObject {
    let id: UUID
    let panelType: PanelType = .terminal
    let surface: TerminalSurface
    private(set) var workspaceId: UUID
    @Published private(set) var title: String = "Terminal"
    @Published private(set) var directory: String = ""
    ...
}
```

### BrowserPanel — Embedded Web View

`BrowserPanel` uses a `WKProcessPool` shared across all browser panels so cookies and sessions are common within a window. It conforms to `Panel` and publishes its current URL as the tab title.

Sources: [Sources/Panels/BrowserPanel.swift:2328-2330]()

### MarkdownPanel

`MarkdownPanel` watches a file on disk with FSEvents and reloads when the file changes. It supports two display modes (`preview` and `text`) toggled without navigating away.

Sources: [Sources/Panels/MarkdownPanel.swift:12-38]()

---

## Splitting Panes

Splitting is initiated through `Workspace.splitPaneWithNewTerminal(...)`. The method:
1. Creates a new `TerminalPanel`.
2. Registers it in `panels` and `surfaceIdToPanelId`.
3. Creates a Bonsplit `Tab` value with the panel's title/icon.
4. Calls `bonsplitController.splitPane(paneId, orientation:withTab:insertFirst:)`.
5. Selects the new tab and calls `focus()` on the new panel.

```swift
// Sources/Workspace.swift:15483-15542
func splitPaneWithNewTerminal(
    targetPane paneId: PaneID,
    orientation: SplitOrientation,
    insertFirst: Bool,
    workingDirectory: String?,
    initialInput: String?,
    remoteStartupCommand: String? = nil
) -> TerminalPanel? {
    ...
    let newTab = Bonsplit.Tab(title: ..., kind: SurfaceKind.terminal, ...)
    surfaceIdToPanelId[newTab.id] = newPanel.id
    guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation,
                                                        withTab: newTab, insertFirst: insertFirst)
    else { ... return nil }
    bonsplitController.selectTab(newTab.id)
    newPanel.focus()
    return newPanel
}
```

The same pattern is used by `splitPaneWithMarkdown` (Sources/Workspace.swift:12972) and `splitPaneWithFilePreview` (Sources/Workspace.swift:13180) to open non-terminal panels in a split.

### Split Lifecycle Callbacks

`Workspace` implements the Bonsplit delegate protocol. Key callbacks include:

| Delegate Method | Triggered When |
|---|---|
| `splitTabBar(_:didSplitPane:newPane:orientation:)` | A pane was split; creates a panel for the new pane unless `isProgrammaticSplit` suppresses it |
| `splitTabBar(_:didClosePane:)` | A pane was closed; cleans up all panels inside it |
| `splitTabBar(_:didSelectTab:inPane:)` | User switches the active tab within a pane |
| `splitTabBar(_:didFocusPane:)` | User clicks a different pane; updates `focusedPanelId` |

Sources: [Sources/Workspace.swift:16560](), [Sources/Workspace.swift:16280](), [Sources/Workspace.swift:16427](), [Sources/Workspace.swift:16485]()

---

## WorkspaceContentView — Rendering the Active Workspace

`WorkspaceContentView` is the SwiftUI `View` responsible for rendering the visible content of a single `Workspace`. It wraps a `BonsplitView` and supplies a closure that maps each Bonsplit tab to its `PanelContentView`:

```swift
// Sources/WorkspaceContentView.swift:148-218
struct WorkspaceContentView: View {
    @ObservedObject var workspace: Workspace
    let isWorkspaceVisible: Bool
    ...
    var body: some View {
        ...
        let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
            if let panel = workspace.panel(for: tab.id) {
                let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
                let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
                PanelContentView(panel: panel, ...)
            }
        }
        ...
    }
}
```

Inactive workspaces stay mounted in a SwiftUI `ZStack` for state preservation (so terminal scrollback isn't lost), but their `bonsplitController.isInteractive` is set to `false` to stop them from intercepting drags. Visibility is gated on both `isWorkspaceVisible` and whether the panel is selected in its pane or currently focused.

---

## Session Persistence

When the app quits, `Workspace.sessionSnapshot(...)` serializes the full workspace state — the Bonsplit tree, all panel types and their content (URLs for browsers, file paths for markdown/file previews, scrollback for terminals), git branch state, and the focused panel ID. On next launch, `restoreSessionSnapshot(_:)` rebuilds the layout tree and restores each panel in order.

The mapping from old panel IDs (in the snapshot file) to new panel IDs (freshly created at launch) is tracked via `oldToNewPanelIds: [UUID: UUID]` so the focused panel is correctly restored even when IDs are regenerated.

Sources: [Sources/Workspace.swift:163-350]()

---

## Summary

cmux's screen organization is a clean three-layer model: `TabManager` holds an ordered list of `Workspace` objects (one per sidebar row); each `Workspace` owns a `BonsplitController` that manages split panes; each pane holds one or more `Panel` instances (terminal, browser, markdown, file preview, or sidebar tool). The bridge between Bonsplit's tab IDs and cmux's panel UUIDs is the `surfaceIdToPanelId` dictionary on `Workspace`. Switching workspaces is as simple as setting `TabManager.selectedTabId`, which triggers focus transfer, history recording, and window title updates in a single `didSet` observer (`Sources/TabManager.swift:1127-1203`).
