How Tabby Implements Split Panes and Tab Management: A Deep Dive into the Tree Model

Tabby implements split panes and tab management using a recursive tree data structure called SplitContainer that hierarchically organizes tabs and nested containers, with Angular components rendering draggable spanners, drop zones, and handling layout persistence through the TabRecovery system.

The open-source terminal emulator Tabby (formerly Terminus) provides a flexible interface for arranging terminal sessions in complex layouts. Unlike simple grid systems, Tabby's approach to split panes and tab management uses a lightweight tree model to represent pane hierarchies, enabling dynamic splitting, dragging, and resizing while maintaining state across sessions. This architecture is implemented primarily within the tabby-core package and centers on the relationship between data structures and their Angular component representations.

The Recursive Tree Architecture

At the heart of Tabby's system lies a recursive tree model that treats every split as a node containing either leaf tabs or additional split containers.

SplitContainer Data Structure

The SplitContainer class, defined in [tabby-core/src/components/splitTab.component.ts](https://github.com/Eugeny/tabby/blob/master/tabby-core/src/components/splitTab.component.ts#L13-L30), serves as the foundational data structure. Each container maintains:

  • orientation: A character flag where 'h' indicates horizontal splitting (side-by-side) and 'v' indicates vertical splitting (stacked)
  • children: An ordered array containing either BaseTabComponent instances (leaf nodes) or nested SplitContainer objects
  • ratios: A parallel array storing each child's size as a fractional proportion of the total container space

This design allows arbitrarily nested layouts where a horizontal split might contain vertical subdivisions, creating complex grid-like arrangements from a simple binary tree structure.

Rendering with SplitTabComponent

The SplitTabComponent acts as the root Angular controller for the entire tree. Implemented in the same file (lines 61-115), it owns the root SplitContainer and manages the translation from data model to DOM elements. Key responsibilities include:

  • Geometry calculation: The layoutInternal method (lines 90-128) recursively walks the tree to compute absolute pixel dimensions for each pane based on the relative ratios
  • Focus management: Tracks the currently active leaf tab (focusedTab) and updates the window title via updateTitle()
  • Hotkey registration: Maps keyboard shortcuts for split directions (right, bottom, top, left) to the splitTab() method during construction

UI Components: Spanners and Drop Zones

Two specialized Angular components handle user interaction with the tree structure:

SplitTabSpannerComponent ([splitTabSpanner.component.ts](https://github.com/Eugeny/tabby/blob/master/tabby-core/src/components/splitTabSpanner.component.ts#L27-L66)): Renders the draggable divider bars between panes. During drag operations, it calculates delta movements in screen space and translates these into ratio adjustments for the adjacent siblings, emitting change events that trigger layout recalculation.

SplitTabDropZoneComponent: Creates invisible rectangular targets around each pane and splitter. Instantiated within layoutInternal (lines 95-119), these zones accept dragged tabs and invoke SplitTabComponent.add() to handle insertion logic.

Adding and Splitting Tabs Programmatically

Tabby provides both user-driven and API-level methods for manipulating the pane tree, with all operations ultimately flowing through the SplitTabComponent class.

The splitTab Method

When users trigger a split action via keyboard shortcut, the splitTab() method (lines 87-102) executes the following sequence:

async splitTab(tab: BaseTabComponent, dir: SplitDirection): Promise<BaseTabComponent|null> {
    const newTab = await this.tabsService.duplicate(tab)   // Clone via recovery token
    if (newTab) {
        await this.addTab(newTab, tab, dir)               // Insert relative to source
    }
    return newTab
}

This approach leverages TabsService.duplicate() to create a functionally identical tab instance before positioning it within the tree.

The add Method and Tree Insertion

The add() method (lines 54-118) contains the core insertion logic for the split panes and tab management system:

  1. Orientation resolution: If the target container's orientation doesn't match the requested split direction, the method inserts a new intermediate SplitContainer to accommodate the layout change (lines 95-107)
  2. Ratio adjustment: Scales existing sibling ratios proportionally to make room for the new pane, then inserts a new ratio value and splices the child into the children array (lines 109-119)
  3. View attachment: Creates Angular component references for new leaf tabs via attachTabView and renders them into the calculated rectangular regions (lines 122-128)
  4. Tree normalization: Calls normalize() to collapse empty containers and flatten single-child nodes, ensuring the tree remains minimal (line 127)

To programmatically split the currently focused pane to the right:

async function splitRight(currentSplit: SplitTabComponent) {
    const focused = currentSplit.getFocusedTab()
    if (!focused) return
    // Duplicate the tab and insert it on the right side of the focused pane
    await currentSplit.splitTab(focused, 'r')
}

To add a brand-new terminal tab next to an existing one:

import { TerminalTabComponent } from './terminalTab.component';

async function addTerminalNextTo(tab: BaseTabComponent, direction: SplitDirection) {
    const newTab = tabsService.create<TerminalTabComponent>({
        type: TerminalTabComponent,
        inputs: { title: 'New terminal' }
    })
    await splitTab.addTab(newTab, tab, direction)   // direction = 'b' | 't' | 'l' | 'r'
}

Normalization and Equalization

The SplitContainer.normalize() method (lines 52-84) maintains tree integrity by removing empty containers, flattening nested containers with single children, and re-balancing the ratios array when structural changes occur. For user-initiated layout resets, root.equalize() (lines 90-96) forces all direct siblings to share equal proportional space by resetting their ratios to uniform values.

Removing Tabs and Container Cleanup

The removeTab() method (lines 30-44) handles tab deletion by locating the tab within its parent container's children array, removing the associated ratio, redistributing the freed space among remaining siblings, and destroying the Angular view reference. When the final pane is removed, the entire SplitTabComponent destroys itself (lines 45-48), ensuring no orphaned containers persist in memory.

Resizing Panes via Mouse and Keyboard

Tabby implements dual input mechanisms for adjusting pane dimensions within the split panes and tab management framework.

Mouse Drag with SplitTabSpannerComponent

The spanner component captures mouse deltas during drag operations and converts screen-space movements into relative ratio adjustments. When the user releases the divider, it emits a change event containing the new ratio values for the two adjacent panes (lines 57-64), which SplitTabComponent applies through the layout() recalculation cycle.

Keyboard Navigation with resizePane

For keyboard-driven workflows, the resizePane() method (lines 70-86) traverses upward through the tree hierarchy to locate a container with matching orientation. Once found, it adjusts the focused pane's ratio by adding or subtracting the configured paneResizeStep value:

// Increase width of focused pane
splitTab.resizePane('h')   // horizontal step +

// Decrease width of focused pane  
splitTab.resizePane('dh')  // decrease horizontal step

Both mechanisms ultimately invoke layout() to synchronize the DOM with the updated tree state.

Tab Lifecycle and Factory Services

Beyond the visual tree, Tabby's split panes and tab management relies on robust factory services for component instantiation and lifecycle management.

TabsService Factory

Located in [tabby-core/src/services/tabs.service.ts](https://github.com/Eugeny/tabby/blob/master/tabby-core/src/services/tabs.service.ts), this singleton handles tab creation through two primary methods:

  • create() (lines 33-43): Resolves the Angular component factory for a given tab type, instantiates it, assigns input properties, and subscribes to the tab's destroyed$ observable to manage cleanup
  • duplicate() (lines 45-56): Generates a recovery token from the source tab, clones its configuration, and invokes create() to produce an identical instance

This factory pattern ensures that split operations always work with properly initialized tab components regardless of the underlying tab type (terminal, SSH, serial, etc.).

BaseTabComponent Abstraction

All leaf nodes in the split tree extend BaseTabComponent ([baseTab.component.ts](https://github.com/Eugeny/tabby/blob/master/tabby-core/src/components/baseTab.component.ts)), which provides focus streams (focused$ and blurred$), activity indicators, and standardized lifecycle hooks (canClose checks). By enforcing this interface, the SplitTabComponent can treat heterogeneous tab types uniformly within the tree structure.

Focus and Navigation

Keyboard navigation between panes uses geometric calculations rather than tree traversal. The navigate() and navigateLinear() methods (lines 78-88) compute the screen rectangle of each visible pane (getPaneRect) and determine the nearest neighbor in the requested cardinal direction, allowing intuitive focus movement regardless of the underlying tree depth.

Persistence and Session Recovery

The split layout achieves serializability through the TabRecovery system. SplitContainer.serialize() (lines 9-23) recursively walks the tree, recording each node's type, orientation, ratios, and child recovery tokens. During session restoration, SplitTabComponent.recoverContainer() (lines 30-58) reconstructs the tree by recursively invoking TabRecoveryService to reinstantiate saved tabs.

To persist and restore a layout:

// Save layout
const token = await splitTab.getRecoveryToken()
localStorage.setItem('myLayout', JSON.stringify(token))

// Later – restore
const saved = JSON.parse(localStorage.getItem('myLayout')!)
await splitTab.recoverContainer(splitTab.root, saved)
splitTab.layout()

Summary

  • Tree Model: Tabby uses a recursive SplitContainer structure with orientation, children, and ratios properties to represent arbitrary nested split layouts in splitTab.component.ts
  • Rendering Pipeline: SplitTabComponent translates the tree into DOM elements using layoutInternal, while SplitTabSpannerComponent and drop zones handle user interactions
  • Tab Operations: The splitTab() and add() methods handle insertion with automatic orientation matching and ratio balancing, while removeTab() manages cleanup and container collapse via normalize()
  • Factory Services: TabsService creates and duplicates tabs via recovery tokens, ensuring type-agnostic handling within the split tree
  • Persistence: Layouts serialize through SplitContainer.serialize() and restore via recoverContainer(), maintaining complex pane arrangements across sessions

Frequently Asked Questions

How does Tabby store the size of each split pane?

Tabby stores pane dimensions within the ratios array of each SplitContainer node. These values represent fractional proportions (0.0 to 1.0) of the parent container's total available space. When the user drags a spanner or triggers a keyboard resize command, the system modifies these ratios and recursively recalculates absolute pixel dimensions through the layout() method in splitTab.component.ts.

Can I programmatically create a split layout without using the UI?

Yes. You can manipulate the split panes and tab management system directly through the SplitTabComponent API. First create a tab using TabsService.create(), then insert it relative to an existing tab using splitTab.addTab(newTab, existingTab, direction) where direction accepts 'r', 'l', 't', or 'b' for right, left, top, and bottom splits respectively. Finally, call layout() to render the changes.

What happens when I close the last tab in a split container?

When removeTab() detects that no children remain in the root container, it triggers the destruction of the entire SplitTabComponent instance (lines 45-48 in splitTab.component.ts). The normalize() method automatically cleans up intermediate containers that become empty during tab removal, ensuring the tree structure remains minimal and memory is properly released through Angular's component lifecycle hooks.

How does Tabby remember my split layout after restarting?

Tabby serializes the entire split tree using SplitContainer.serialize(), which captures the orientation, ratios, and recovery tokens for each tab. This data is stored through the TabRecoveryService. On application launch, SplitTabComponent.recoverContainer() reconstructs the tree by deserializing the saved structure and recreating each tab via its stored recovery token, then applies the saved ratios to restore the exact geometric layout.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →