feat(layout): relax MAM_MIN_PANE_COLS to 15 and remove vertical height constraints for scrollable terminal split

This commit is contained in:
2026-08-26 09:44:02 +09:00
parent d1efce2971
commit f3ac68f36d
5 changed files with 134 additions and 166 deletions
+1 -1
View File
@@ -429,7 +429,7 @@ except Exception:
split_dir=""
if [ -n "$sample_pane" ]; then
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-40}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${split_dir:-right}"
sample_pane="${split_target:-$sample_pane}"
fi
+9 -9
View File
@@ -70,8 +70,8 @@ def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
def compute_2xk_layout(
data: Dict[str, Any],
min_cols: int = 40,
min_rows: int = 20,
min_cols: int = 15,
min_rows: int = 0,
max_columns: Optional[int] = None,
default_anchor_id: Optional[str] = None
) -> LayoutDecision:
@@ -90,8 +90,8 @@ def compute_2xk_layout(
if len(panes) == 1:
p = panes[0]
# In 2xK grid, 1 pane -> 2 panes: split down to create top and bottom rows
# Check height overflow if dimensions known
if p.height > 0 and p.height // 2 < min_rows:
# Check height overflow only if min_rows > 0 is explicitly configured
if min_rows > 0 and p.height > 0 and p.height // 2 < min_rows:
# If height is too small for 2 rows, try splitting right if width allows
if p.width > 0 and p.width // 2 >= min_cols:
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained")
@@ -153,8 +153,8 @@ def compute_2xk_layout(
if singleton_col is not None:
target_p = singleton_col[0]
# Check height
if target_p.height > 0 and target_p.height // 2 < min_rows:
# Check height only if min_rows > 0
if min_rows > 0 and target_p.height > 0 and target_p.height // 2 < min_rows:
return LayoutDecision(target_pane_id=target_p.pane_id, direction="overflow", is_overflow=True, reason="singleton_height_overflow")
return LayoutDecision(target_pane_id=target_p.pane_id, direction="down", reason="fill_singleton_column")
@@ -176,7 +176,7 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
"""First *valid* int among the env vars in *names*, else `default`.
`default` is an explicit parameter rather than an `or` at the call site so a
legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 40 default).
legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 15 default).
An unparsable value is skipped rather than raised or treated as terminal: a
typo in an operator's shell must not take the whole layout call down (lib.sh
@@ -198,8 +198,8 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
def main():
parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction")
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=40))
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=20))
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=15))
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=0))
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
parser.add_argument("--sample-pane", type=str, default=None)
parser.add_argument("--json", action="store_true", help="Output full JSON decision")
+7 -6
View File
@@ -128,13 +128,14 @@
#default: 3
# SKS_EMPTY_GIVEUP=3
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
#default: 40
# MAM_MIN_PANE_COLS=40
# Minimum columns a pane must retain after a horizontal/column split (2xK layout engine).
#default: 15
# MAM_MIN_PANE_COLS=15
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
#default: 20
# MAM_MIN_PANE_ROWS=20
# Minimum rows a pane must retain after a vertical split (2xK layout engine).
# Set to 0 to disable vertical row constraints (terminal scrollback handles height).
#default: 0
# MAM_MIN_PANE_ROWS=0
# Maximum number of columns a workspace may grow to before the engine reports
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
+66 -96
View File
@@ -287,7 +287,7 @@ split_dir=""
# Exact snippet from lib.sh:429-435
if [ -n "$sample_pane" ]; then
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-40}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-15}}" --min-rows "${{MAM_MIN_PANE_ROWS:-0}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${{split_dir:-right}}"
sample_pane="${{split_target:-$sample_pane}}"
fi
@@ -435,11 +435,11 @@ _ZERO_TRAP = {"result": {"panes": [
def test_j1_env_zero_min_cols_matches_flag_zero():
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 40 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0"))
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 15 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20"))
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {var: "0"}) == flag, var
def test_j1_env_zero_min_rows_matches_flag_zero():
@@ -452,8 +452,8 @@ def test_j1_env_zero_min_rows_matches_flag_zero():
def test_j1_nonzero_and_malformed_env_behaviour_unchanged():
"""Behaviour neutrality: non-zero env still applies, and a lone typo still
lands on the documented default instead of crashing on a None comparison."""
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "25"))
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25", "MAM_MIN_PANE_ROWS": "20"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "25", "--min-rows", "20"))
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP)
@@ -465,32 +465,41 @@ def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
Empty values already fell through (`if raw:`); this makes invalid values
behave the same way. When every candidate is unusable, `default` still wins.
"""
good = _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"})
good = _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_PANE_COLS": "25"})
assert good["direction"] == "right"
# 별칭이 깨져 있어도 문서화된 변수가 적용된다
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "25"}) == good
# 0 도 마찬가지 (J-1 과의 상호작용)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "0"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "0"))
_run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20"))
# 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)
def test_default_min_cols_is_40():
"""Verify compute_2xk_layout default min_cols is 40.
With width 80 (width//2 = 40):
- min_cols=40 -> 40 >= 40 -> split right (new column).
- min_cols=60 -> 40 < 60 -> overflow.
Default invocation (no min_cols passed) must split right.
def test_default_min_cols_is_15_and_min_rows_is_0():
"""Verify compute_2xk_layout default min_cols is 15 and min_rows is 0.
With 1 pane of 54x23:
- min_rows=0 -> splits down without height overflow
With 2 panes of 30x23 (width//2 = 15):
- min_cols=15 -> 15 >= 15 -> split right (new column).
- min_cols=40 -> 15 < 40 -> overflow.
Default invocation (no min_cols/min_rows passed) must split right for 30 cols.
"""
# 1. 1 pane of 54x23 splits down (no height constraint)
p1_54x23 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
d1 = compute_2xk_layout(p1_54x23)
assert d1.direction == "down"
assert not d1.is_overflow
# 2. 2 panes with width 30 (30 // 2 = 15 == min_cols 15) -> splits right cleanly
payload = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
]
}
}
@@ -500,118 +509,79 @@ def test_default_min_cols_is_40():
assert decision.reason == "new_column_right"
def test_80_col_2_column_splitting_boundary():
"""Verify width >= 80 cols allows 2-column splitting with default min_cols=40,
while width < 80 (e.g. 79) triggers column_width_overflow.
def test_30_col_2_column_splitting_boundary():
"""Verify width >= 30 cols allows 2-column splitting with default min_cols=15,
while width < 30 (e.g. 29) triggers column_width_overflow.
"""
# 80 cols: 80 // 2 = 40 == min_cols(40) -> splits right
payload_80 = {
# 30 cols: 30 // 2 = 15 == min_cols(15) -> splits right
payload_30 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
]
}
}
d80 = compute_2xk_layout(payload_80)
assert d80.direction == "right"
assert not d80.is_overflow
d30 = compute_2xk_layout(payload_30)
assert d30.direction == "right"
assert not d30.is_overflow
# 79 cols: 79 // 2 = 39 < min_cols(40) -> overflow
payload_79 = {
# 29 cols: 29 // 2 = 14 < min_cols(15) -> overflow
payload_29 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}},
]
}
}
d79 = compute_2xk_layout(payload_79)
assert d79.direction == "overflow"
assert d79.is_overflow
assert d79.reason == "column_width_overflow"
d29 = compute_2xk_layout(payload_29)
assert d29.direction == "overflow"
assert d29.is_overflow
assert d29.reason == "column_width_overflow"
def test_90_col_single_workspace_multi_pane_tiling():
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 90-col single workspace.
- 1 pane (90x40): splits down to p1(90x20), p2(90x20)
- 2 panes: splits right to start col 2 -> p3(45x40)
- 3 panes: fills singleton col 2 down -> p4(45x20)
- 4 panes (2x2 grid): 5th agent overflows because 45 // 2 = 22 < 40
def test_54x23_compact_viewport_single_workspace_multi_pane_tiling():
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in standard 80x24 (54x23 content area) workspace.
- 1 pane (54x23): splits down to p1(54x11), p2(54x11)
- 2 panes: splits right (54//2 = 27 >= 15) to start col 2 -> p3(27x23)
- 3 panes: fills singleton col 2 down -> p4(27x11)
- 4 panes (2x2 grid): 5th agent overflows because 27 // 2 = 13 < 15
"""
# 1 -> 2
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 40}}]}}
# 1 -> 2 (splits down)
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
d1 = compute_2xk_layout(p1_layout)
assert d1.direction == "down"
assert d1.target_pane_id == "p1"
assert not d1.is_overflow
# 2 -> 3
# 2 -> 3 (splits right)
p2_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 90, "height": 20}},
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 54, "height": 11}},
]}}
d2 = compute_2xk_layout(p2_layout)
assert d2.direction == "right"
assert d2.target_pane_id == "p1"
assert not d2.is_overflow
# 3 -> 4
# 3 -> 4 (fills singleton col 2 down)
p3_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}},
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 40}},
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}},
]}}
d3 = compute_2xk_layout(p3_layout)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert not d3.is_overflow
# 4 -> 5 (overflow to new workspace)
# 4 -> 5 (overflow to new workspace because 27 // 2 = 13 < 15)
p4_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}},
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 20}},
{"pane_id": "p4", "rect": {"x": 45, "y": 20, "width": 45, "height": 20}},
]}}
d4 = compute_2xk_layout(p4_layout)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"
def test_100_col_single_workspace_multi_pane_tiling():
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 100-col single workspace."""
# 1 -> 2
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}]}}
d1 = compute_2xk_layout(p1_layout)
assert d1.direction == "down"
# 2 -> 3
p2_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 100, "height": 20}},
]}}
d2 = compute_2xk_layout(p2_layout)
assert d2.direction == "right"
assert not d2.is_overflow
# 3 -> 4
p3_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 40}},
]}}
d3 = compute_2xk_layout(p3_layout)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert not d3.is_overflow
# 4 -> 5 (overflow to new workspace)
p4_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p4", "rect": {"x": 50, "y": 20, "width": 50, "height": 20}},
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 11}},
]}}
d4 = compute_2xk_layout(p4_layout)
assert d4.direction == "overflow"
+51 -54
View File
@@ -865,88 +865,85 @@ def test_lib_sh_new_session_passes_mam_ws_label(mam_sandbox):
# ==============================================================================
# FEATURE: 2xK Grid Layout Engine (min_cols=40 & multi-pane workspace tiling)
# FEATURE: 2xK Grid Layout Engine (min_cols=15 & min_rows=0 multi-pane workspace tiling)
# ==============================================================================
def test_layout_default_min_cols_40_in_tier1():
"""Verify default min_cols=40 behavior across compute_2xk_layout in Tier 1 suite."""
def test_layout_default_min_cols_15_in_tier1():
"""Verify default min_cols=15 behavior across compute_2xk_layout in Tier 1 suite."""
from lib_py.layout import compute_2xk_layout
# 1. 2 panes in 80 col width (80 // 2 = 40 == min_cols 40) -> splits right cleanly
payload_80 = {
# 1. 2 panes in 30 col width (30 // 2 = 15 == min_cols 15) -> splits right cleanly
payload_30 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}}
]
}
}
decision = compute_2xk_layout(payload_80)
decision = compute_2xk_layout(payload_30)
assert decision.direction == "right"
assert not decision.is_overflow
assert decision.reason == "new_column_right"
# 2. 2 panes in 79 col width (79 // 2 = 39 < min_cols 40) -> column_width_overflow
payload_79 = {
# 2. 2 panes in 29 col width (29 // 2 = 14 < min_cols 15) -> column_width_overflow
payload_29 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}}
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}}
]
}
}
decision_overflow = compute_2xk_layout(payload_79)
decision_overflow = compute_2xk_layout(payload_29)
assert decision_overflow.direction == "overflow"
assert decision_overflow.is_overflow
assert decision_overflow.reason == "column_width_overflow"
def test_layout_single_workspace_90_100_cols_tiling_tier1():
"""Verify 3-4 agents tiling in standard 90-100 col terminal windows within a single workspace."""
def test_layout_single_workspace_54x23_compact_tiling_tier1():
"""Verify 3-4 agents tiling in standard 80x24 (54x23 content) terminal windows within a single workspace."""
from lib_py.layout import compute_2xk_layout
for total_w in [90, 100]:
half_w = total_w // 2
# Step 1: 1 pane -> 2 panes (split down without height constraint)
p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
d1 = compute_2xk_layout(p1)
assert d1.direction == "down"
assert d1.target_pane_id == "p1"
assert not d1.is_overflow
# Step 1: 1 pane -> 2 panes (split down)
p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 40}}]}}
d1 = compute_2xk_layout(p1)
assert d1.direction == "down"
assert d1.target_pane_id == "p1"
assert not d1.is_overflow
# Step 2: 2 panes -> 3 panes (split right to open 2nd column)
p2 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 54, "height": 11}}
]}}
d2 = compute_2xk_layout(p2)
assert d2.direction == "right"
assert d2.target_pane_id == "p1"
assert not d2.is_overflow
# Step 2: 2 panes -> 3 panes (split right to open 2nd column)
p2 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": total_w, "height": 20}}
]}}
d2 = compute_2xk_layout(p2)
assert d2.direction == "right"
assert d2.target_pane_id == "p1"
assert not d2.is_overflow
# Step 3: 3 panes -> 4 panes (split singleton 2nd column down)
p3 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}}
]}}
d3 = compute_2xk_layout(p3)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert not d3.is_overflow
# Step 3: 3 panes -> 4 panes (split singleton 2nd column down)
p3 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}},
{"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 40}}
]}}
d3 = compute_2xk_layout(p3)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert not d3.is_overflow
# Step 4: 4 panes (2x2 complete) -> 5th agent overflows to fresh workspace
p4 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}},
{"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 20}},
{"pane_id": "p4", "rect": {"x": half_w, "y": 20, "width": half_w, "height": 20}}
]}}
d4 = compute_2xk_layout(p4)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"
# Step 4: 4 panes (2x2 complete) -> 5th agent overflows to fresh workspace (27 // 2 = 13 < 15)
p4 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 11}}
]}}
d4 = compute_2xk_layout(p4)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"