From 5f7b13e01e15121f040e231bffa5abb3d7e9d604 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 15 May 2026 13:37:28 -0700
Subject: [PATCH] Add terminal pane equalize action
Add a terminal context menu action that equalizes split pane sizes and handles nested split weighting.
---
.../terminal-pane/TerminalContextMenu.tsx | 11 +++
.../components/terminal-pane/TerminalPane.tsx | 2 +
.../use-terminal-pane-context-menu.ts | 12 ++++
.../src/lib/pane-manager/pane-manager.ts | 17 ++++-
.../lib/pane-manager/pane-tree-ops.test.ts | 69 ++++++++++++++++++-
.../src/lib/pane-manager/pane-tree-ops.ts | 51 ++++++++++++++
6 files changed, 159 insertions(+), 3 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
index f1946791c..f4fce7e40 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -5,6 +5,7 @@ import {
Maximize2,
Minimize2,
PanelBottomClose,
+ PanelsTopLeft,
PanelRightClose,
Pencil,
SquareTerminal,
@@ -36,6 +37,8 @@ type TerminalContextMenuProps = {
onPaste: () => void
onSplitRight: () => void
onSplitDown: () => void
+ canEqualizePaneSizes: boolean
+ onEqualizePaneSizes: () => void
onClosePane: () => void
onClearScreen: () => void
quickCommands: TerminalQuickCommand[]
@@ -56,6 +59,8 @@ export default function TerminalContextMenu({
onPaste,
onSplitRight,
onSplitDown,
+ canEqualizePaneSizes,
+ onEqualizePaneSizes,
onClosePane,
onClearScreen,
quickCommands,
@@ -154,6 +159,12 @@ export default function TerminalContextMenu({
Ctrl+Shift+D is taken by split-right (#586). */}
{isMac ? `${mod}${shift}D` : `Alt+${shift}D`}
+ {canEqualizePaneSizes && (
+
+
+ Equalize Pane Sizes
+
+ )}
{canExpandPane && (
{menuPaneIsExpanded ? : }
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index f891f762d..da162b18c 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -1150,6 +1150,7 @@ export default function TerminalPane({
menuOpenedAtRef={contextMenu.menuOpenedAtRef}
canClosePane={contextMenu.paneCount > 1}
canExpandPane={contextMenu.paneCount > 1}
+ canEqualizePaneSizes={contextMenu.paneCount > 1 && expandedPaneId === null}
menuPaneIsExpanded={
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
}
@@ -1157,6 +1158,7 @@ export default function TerminalPane({
onPaste={() => void contextMenu.onPaste()}
onSplitRight={contextMenu.onSplitRight}
onSplitDown={contextMenu.onSplitDown}
+ onEqualizePaneSizes={contextMenu.onEqualizePaneSizes}
onClosePane={contextMenu.onClosePane}
onClearScreen={contextMenu.onClearScreen}
quickCommands={(settings?.terminalQuickCommands ?? []).filter(
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
index c41841921..e265ca980 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
@@ -30,6 +30,7 @@ type TerminalMenuState = {
onPaste: () => Promise
onSplitRight: () => void
onSplitDown: () => void
+ onEqualizePaneSizes: () => void
onClosePane: () => void
onClearScreen: () => void
onQuickCommand: (command: TerminalQuickCommand) => void
@@ -148,6 +149,16 @@ export function useTerminalPaneContextMenu({
const onSplitRight = (): void => splitWithInheritedCwd('vertical')
const onSplitDown = (): void => splitWithInheritedCwd('horizontal')
+ const onEqualizePaneSizes = (): void => {
+ const pane = resolveMenuPane()
+ const manager = managerRef.current
+ if (!pane || !manager) {
+ return
+ }
+ manager.equalizePaneSizes()
+ pane.terminal.focus()
+ }
+
const onClosePane = (): void => {
const pane = resolveMenuPane()
if (pane && (managerRef.current?.getPanes().length ?? 0) > 1) {
@@ -241,6 +252,7 @@ export function useTerminalPaneContextMenu({
onPaste,
onSplitRight,
onSplitDown,
+ onEqualizePaneSizes,
onClosePane,
onClearScreen,
onQuickCommand,
diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts
index 5ead38cc9..6923fa333 100644
--- a/src/renderer/src/lib/pane-manager/pane-manager.ts
+++ b/src/renderer/src/lib/pane-manager/pane-manager.ts
@@ -14,7 +14,7 @@ import {
import { createDragReorderState, hideDropOverlay, handlePaneDrop } from './pane-drag-reorder'
import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
-import { safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops'
+import { equalizePaneSplitSizes, safeFit, fitAllPanesInternal, refitPanesUnder } from './pane-tree-ops'
import { toPublicPane } from './pane-public-view'
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import {
@@ -119,6 +119,21 @@ export class PaneManager {
fitAllPanesInternal(this.panes)
}
+ equalizePaneSizes(): void {
+ if (this.panes.size < 2) {
+ return
+ }
+
+ const changed = equalizePaneSplitSizes(
+ this.root.firstElementChild instanceof HTMLElement ? this.root.firstElementChild : null
+ )
+ if (!changed) {
+ return
+ }
+
+ this.options.onLayoutChanged?.()
+ }
+
getActivePane(): ManagedPane | null {
if (this.activePaneId === null) {
return null
diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
index b163d4934..577dd8e02 100644
--- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
@@ -1,8 +1,24 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { safeFit } from './pane-tree-ops'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops'
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides'
+class MockHTMLElement {
+ classList: { contains: (cls: string) => boolean }
+ children: MockHTMLElement[]
+ style: Record
+
+ constructor(classes: string[], children: MockHTMLElement[] = [], flex = '') {
+ this.classList = { contains: (cls: string) => classes.includes(cls) }
+ this.children = children
+ this.style = { flex }
+ }
+}
+
+beforeAll(() => {
+ ;(globalThis as unknown as Record).HTMLElement = MockHTMLElement
+})
+
afterEach(() => {
hydrateOverrides([])
})
@@ -214,3 +230,52 @@ describe('safeFit', () => {
expect(paneB.terminal.resize).not.toHaveBeenCalled()
})
})
+
+describe('equalizePaneSplitSizes', () => {
+ const pane = (flex = '1 1 0%'): MockHTMLElement => new MockHTMLElement(['pane'], [], flex)
+ const split = (
+ direction: 'vertical' | 'horizontal',
+ children: MockHTMLElement[],
+ flex = '1 1 0%'
+ ): MockHTMLElement =>
+ new MockHTMLElement(
+ ['pane-split', direction === 'vertical' ? 'is-vertical' : 'is-horizontal'],
+ children,
+ flex
+ )
+
+ it('weights nested same-axis splits so same-axis panes equalize evenly', () => {
+ const left = pane('10 1 0%')
+ const middle = pane('20 1 0%')
+ const right = pane('30 1 0%')
+ const rightSplit = split('vertical', [middle, right], '90 1 0%')
+ const root = split('vertical', [left, rightSplit])
+
+ expect(equalizePaneSplitSizes(root as unknown as HTMLElement)).toBe(true)
+
+ expect(left.style.flex).toBe('1 1 0%')
+ expect(rightSplit.style.flex).toBe('2 1 0%')
+ expect(middle.style.flex).toBe('1 1 0%')
+ expect(right.style.flex).toBe('1 1 0%')
+ })
+
+ it('treats perpendicular child splits as one weighted region', () => {
+ const top = pane('7 1 0%')
+ const bottom = pane('3 1 0%')
+ const leftStack = split('horizontal', [top, bottom], '15 1 0%')
+ const right = pane('85 1 0%')
+ const root = split('vertical', [leftStack, right])
+
+ expect(equalizePaneSplitSizes(root as unknown as HTMLElement)).toBe(true)
+
+ expect(leftStack.style.flex).toBe('1 1 0%')
+ expect(right.style.flex).toBe('1 1 0%')
+ expect(top.style.flex).toBe('1 1 0%')
+ expect(bottom.style.flex).toBe('1 1 0%')
+ })
+
+ it('returns false when there is no split tree to change', () => {
+ expect(equalizePaneSplitSizes(pane() as unknown as HTMLElement)).toBe(false)
+ expect(equalizePaneSplitSizes(null)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts
index 4359e4fd7..cdb97e47f 100644
--- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts
+++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts
@@ -280,6 +280,57 @@ export function findPaneChildren(parent: HTMLElement): HTMLElement[] {
)
}
+function getSplitDirection(split: HTMLElement): 'vertical' | 'horizontal' {
+ return split.classList.contains('is-horizontal') ? 'horizontal' : 'vertical'
+}
+
+function getEqualizeWeight(el: HTMLElement, direction: 'vertical' | 'horizontal'): number {
+ if (!el.classList.contains('pane-split') || getSplitDirection(el) !== direction) {
+ return 1
+ }
+
+ const children = findPaneChildren(el)
+ return Math.max(
+ 1,
+ children.reduce((sum, child) => sum + getEqualizeWeight(child, direction), 0)
+ )
+}
+
+export function equalizePaneSplitSizes(root: HTMLElement | null): boolean {
+ if (!root) {
+ return false
+ }
+
+ let changed = false
+ const visit = (el: HTMLElement): void => {
+ if (!el.classList.contains('pane-split')) {
+ return
+ }
+
+ const direction = getSplitDirection(el)
+ const children = findPaneChildren(el)
+ if (children.length >= 2) {
+ for (const child of children) {
+ // Why: same-axis nested splits need pane-count weighting so three
+ // side-by-side panes become thirds, not 50/25/25.
+ const weight = getEqualizeWeight(child, direction)
+ const nextFlex = `${weight} 1 0%`
+ if (child.style.flex !== nextFlex) {
+ child.style.flex = nextFlex
+ changed = true
+ }
+ }
+ }
+
+ for (const child of children) {
+ visit(child)
+ }
+ }
+
+ visit(root)
+ return changed
+}
+
/**
* Create a flex split wrapper that replaces `existingContainer` in the DOM,
* then places [existing] [divider] [new] inside it.