feat(layout): implement deterministic 2xK grid engine with single decision table

- Change N=1->2 split policy from down to right to establish 2 full-height columns
- Share single decision table across GUI and headless modes
- Add _full_height_pane guard to prevent half-height column splits
- Quadruple-wire default max_columns=2 and max_rows=2 across layout.py, lib.sh, and .mam.env.example
- Update unit and integration tests to enforce deterministic 2x2 grid trajectory
This commit is contained in:
2026-08-26 15:10:06 +09:00
parent 5ed9ec79d7
commit 80d2f7f068
6 changed files with 338 additions and 220 deletions
+1 -1
View File
@@ -432,7 +432,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:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --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}" --max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${split_dir:-right}"
sample_pane="${split_target:-$sample_pane}"
fi
+125 -91
View File
@@ -68,108 +68,140 @@ def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
return panes
def _pane_id(pane: Any) -> str:
if isinstance(pane, PaneInfo):
return pane.pane_id
return str(pane or "")
def _right(reason: str, pane: Any) -> LayoutDecision:
return LayoutDecision(target_pane_id=_pane_id(pane), direction="right", reason=reason)
def _down(reason: str, pane: Any) -> LayoutDecision:
return LayoutDecision(target_pane_id=_pane_id(pane), direction="down", reason=reason)
def _overflow(reason: str, pane: Any) -> LayoutDecision:
return LayoutDecision(
target_pane_id=_pane_id(pane), direction="overflow", is_overflow=True, reason=reason
)
def _full_height_pane(col: List[PaneInfo], area_h: int, tol: int = 2) -> Optional[PaneInfo]:
"""Single pane that occupies the full column height. Else None (R-2)."""
if len(col) != 1:
return None
if area_h <= 0:
return col[0]
return col[0] if abs(col[0].height - area_h) <= tol else None
def _group_columns(panes: List[PaneInfo]) -> List[List[PaneInfo]]:
columns: List[List[PaneInfo]] = []
for p in sorted(panes, key=lambda q: (q.x, q.y)):
matched = False
for col in columns:
if abs(col[0].x - p.x) <= 2:
col.append(p)
matched = True
break
if not matched:
columns.append([p])
for col in columns:
col.sort(key=lambda q: q.y)
return columns
def _area_height(data: Dict[str, Any], panes: List[PaneInfo]) -> int:
res = data.get("result") if isinstance(data, dict) else {}
if not isinstance(res, dict):
res = {}
layout = res.get("layout")
if isinstance(layout, dict):
area = layout.get("area")
if isinstance(area, dict) and area.get("height"):
try:
return int(area["height"])
except (TypeError, ValueError):
pass
if not panes:
return 0
return max(p.y + p.height for p in panes)
def _decide(
cols: List[List[PaneInfo]],
area_h: int,
max_columns: int,
max_rows: int,
min_cols: int,
min_rows: int,
) -> LayoutDecision:
"""Shared decision table (GUI observation)."""
if not cols:
return _right("no_panes_default", "")
# ① New column only from a full-height pane (R-2).
if len(cols) < max_columns:
fh = _full_height_pane(cols[-1], area_h)
if fh is not None:
if fh.width > 0 and fh.width // 2 < min_cols:
return _overflow("column_width_overflow", fh)
return _right("new_column_right", fh)
# ② Fill the shortest column (leftmost on ties).
shortest = min(cols, key=lambda c: (len(c), c[0].x))
if len(shortest) < max_rows:
bottom = shortest[-1]
if min_rows > 0 and bottom.height > 0 and bottom.height // 2 < min_rows:
return _overflow("row_height_overflow", bottom)
return _down("fill_column", bottom)
# ③ Capacity reached.
return _overflow("grid_capacity_reached", cols[-1][0])
def _decide_headless(
panes: List[PaneInfo], max_columns: int, max_rows: int
) -> LayoutDecision:
"""Same table as _decide, observed from creation order (0x0 panes)."""
n = len(panes)
if n >= max_columns * max_rows:
return _overflow("grid_capacity_reached", panes[-1])
if n < max_columns:
return _right("new_column_right", panes[n - 1])
return _down("fill_column", panes[n - max_columns])
def compute_2xk_layout(
data: Dict[str, Any],
min_cols: int = 15,
min_rows: int = 0,
max_columns: Optional[int] = None,
max_columns: Optional[int] = 2,
max_rows: Optional[int] = 2,
default_anchor_id: Optional[str] = None
) -> LayoutDecision:
"""Balanced 2xK grid. Herdr-supported splits only: 'right' and 'down'.
GUI and headless share one decision table. Observation differs:
geometry grouping vs creation-order column occupancy.
"""
Computes optimal target pane and direction to maintain a balanced 2xK grid.
Only uses Herdr-supported split directions: 'right' and 'down'.
"""
if max_columns is None:
max_columns = 2
if max_rows is None:
max_rows = 2
panes = extract_panes(data)
if not panes:
# no_panes_default: when herdr returns no panes (empty workspace), default to 'right'
target = default_anchor_id or ""
return LayoutDecision(target_pane_id=target, direction="right", is_overflow=False, reason="no_panes_default")
return _right("no_panes_default", default_anchor_id or "")
# If only 1 pane in workspace
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 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")
elif p.width > 0 and p.width // 2 < min_cols:
return LayoutDecision(target_pane_id=p.pane_id, direction="overflow", is_overflow=True, reason="single_pane_overflow")
else:
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained_unknown_width")
return LayoutDecision(target_pane_id=p.pane_id, direction="down", reason="single_pane_split_down")
if all(p.width <= 0 or p.height <= 0 for p in panes):
return _decide_headless(panes, max_columns, max_rows)
# Check for Headless mode: all panes have width <= 0 or height <= 0
is_headless = all(p.width <= 0 or p.height <= 0 for p in panes)
if is_headless:
# Headless panes are all 0x0, so columns cannot be counted from geometry
# the way the GUI path does. The alternation below (odd -> down,
# even -> right) is what builds the grid, so while that invariant holds
# the completed-column count is exactly n // 2. If panes were closed and
# the shape drifted, an odd n is absorbed by the `down` branch and the
# estimate self-corrects at the next even n.
n = len(panes)
anchor = default_anchor_id or panes[-1].pane_id
if n % 2 == 1:
# Filling an existing column never opens a new one, so max_columns is
# deliberately NOT checked here -- this mirrors the GUI path, where
# `fill_singleton_column` also ignores the cap. max_columns is a
# growth guard, not an invariant over the existing layout.
return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
current_cols = n // 2
if max_columns and current_cols >= max_columns:
return LayoutDecision(target_pane_id=anchor, direction="overflow",
is_overflow=True, reason="max_columns_reached")
return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
# Geometry-aware column grouping
# Group panes into columns by X coordinate (fuzz threshold 2 cols)
sorted_by_x = sorted(panes, key=lambda p: (p.x, p.y))
columns: List[List[PaneInfo]] = []
for p in sorted_by_x:
matched_col = False
for col in columns:
if abs(col[0].x - p.x) <= 2:
col.append(p)
matched_col = True
break
if not matched_col:
columns.append([p])
# Sort each column's panes by Y coordinate (top to bottom)
for col in columns:
col.sort(key=lambda p: p.y)
num_cols = len(columns)
# 1. Check for any singleton column (column with only 1 pane spanning full height)
singleton_col = None
for col in columns:
if len(col) == 1:
singleton_col = col
break
if singleton_col is not None:
target_p = singleton_col[0]
# 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")
# 2. All existing columns have 2 (or more) panes -> we need to start a NEW column to the right
if max_columns and num_cols >= max_columns:
return LayoutDecision(target_pane_id=columns[-1][0].pane_id, direction="overflow", is_overflow=True, reason="max_columns_reached")
# Target the top pane of the rightmost column to split right
rightmost_top_pane = columns[-1][0]
# Check width constraint on the rightmost column
if rightmost_top_pane.width > 0 and rightmost_top_pane.width // 2 < min_cols:
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="overflow", is_overflow=True, reason="column_width_overflow")
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="right", reason="new_column_right")
cols = _group_columns(panes)
return _decide(cols, _area_height(data, panes), max_columns, max_rows, min_cols, min_rows)
def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
@@ -200,7 +232,8 @@ 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=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("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS", default=2))
parser.add_argument("--max-rows", type=int, default=_env_int("MAM_MAX_ROWS", "MAM_MAX_PANE_ROWS", default=2))
parser.add_argument("--sample-pane", type=str, default=None)
parser.add_argument("--json", action="store_true", help="Output full JSON decision")
@@ -219,6 +252,7 @@ def main():
min_cols=args.min_cols,
min_rows=args.min_rows,
max_columns=args.max_cols,
max_rows=args.max_rows,
default_anchor_id=args.sample_pane
)
+7 -2
View File
@@ -144,8 +144,13 @@
# 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).
# Applies to both measured (GUI) and headless 0x0 layouts.
#default: (unset -> no column cap)
# MAM_MAX_PANE_COLS=3
#default: 2
# MAM_MAX_PANE_COLS=2
# Maximum number of rows per column before overflow. Default 2 guarantees an
# even 2x2 grid; do not raise this until a resize-normalisation pass exists.
#default: 2
# MAM_MAX_PANE_ROWS=2
# ==============================================================================
# deploy / distribution source (for forks/mirrors)
+8 -3
View File
@@ -28,11 +28,16 @@ def test_bug2_headless_layout_does_not_overflow():
assert not d_wide.is_overflow
assert d_wide.direction == "right"
# 4. Tall pane (split down)
# 4. Tall-but-narrow pane: N=1 is always right; 80//2 < min_cols 60 → overflow
payload_tall = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 80, "height": 60}}]}}
d_tall = compute_2xk_layout(payload_tall, min_cols=60, min_rows=20)
assert not d_tall.is_overflow
assert d_tall.direction == "down"
assert d_tall.is_overflow
assert d_tall.direction == "overflow"
payload_tall_wide = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 160, "height": 60}}]}}
d_tall_wide = compute_2xk_layout(payload_tall_wide, min_cols=60, min_rows=20)
assert not d_tall_wide.is_overflow
assert d_tall_wide.direction == "right"
def test_bug3_reconcile_skills_dir_passed_and_fallback():
+184 -102
View File
@@ -17,7 +17,7 @@ def test_empty_or_malformed_json_fallback():
assert not d.is_overflow
def test_1_pane_split_down():
def test_1_pane_split_right():
payload = {
"result": {
"panes": [
@@ -27,8 +27,9 @@ def test_1_pane_split_down():
}
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
assert d.target_pane_id == "p1"
assert d.direction == "down"
assert d.direction == "right"
assert not d.is_overflow
assert d.reason == "new_column_right"
def test_1_pane_height_constrained_splits_right():
@@ -59,19 +60,20 @@ def test_1_pane_overflow():
assert d.is_overflow
def test_2_panes_to_3_panes_new_column_right():
def test_2_panes_fill_left_column_down():
payload = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 160, "height": 40}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 160, "height": 40}}
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 80}},
{"pane_id": "p2", "rect": {"x": 80, "y": 0, "width": 80, "height": 80}}
]
}
}
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
assert d.target_pane_id == "p1"
assert d.direction == "right"
assert d.direction == "down"
assert not d.is_overflow
assert d.reason == "fill_column"
def test_3_panes_to_4_panes_fill_singleton():
@@ -88,9 +90,10 @@ def test_3_panes_to_4_panes_fill_singleton():
assert d.target_pane_id == "p3"
assert d.direction == "down"
assert not d.is_overflow
assert d.reason == "fill_column"
def test_4_panes_to_5_panes_new_column():
def test_4_panes_to_5_panes_overflows_at_capacity():
payload = {
"result": {
"panes": [
@@ -102,9 +105,9 @@ def test_4_panes_to_5_panes_new_column():
}
}
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
assert d.target_pane_id == "p3"
assert d.direction == "right"
assert not d.is_overflow
assert d.direction == "overflow"
assert d.is_overflow
assert d.reason == "grid_capacity_reached"
def test_4_panes_overflow_when_width_constrained():
@@ -140,33 +143,35 @@ def test_max_columns_limit():
def test_headless_0x0_transitions():
# N=1 -> down
p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}]}}
assert compute_2xk_layout(p1).direction == "down"
assert compute_2xk_layout(p1).direction == "right"
# N=2 -> right
p2 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
]}}
assert compute_2xk_layout(p2).direction == "right"
d2 = compute_2xk_layout(p2)
assert d2.direction == "down"
assert d2.target_pane_id == "p1"
# N=3 -> down
p3 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
]}}
assert compute_2xk_layout(p3).direction == "down"
d3 = compute_2xk_layout(p3)
assert d3.direction == "down"
assert d3.target_pane_id == "p2"
# N=4 -> right
p4 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
{"pane_id": "p4", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
]}}
assert compute_2xk_layout(p4).direction == "right"
d4 = compute_2xk_layout(p4)
assert d4.direction == "overflow"
assert d4.is_overflow
def test_real_herdr_080_nested_layout_format():
@@ -211,7 +216,7 @@ def test_cli_invocation_pipe():
env=env
)
assert res.returncode == 0
assert res.stdout.strip() == "down p1"
assert res.stdout.strip() == "right p1"
res_json = subprocess.run(
[sys.executable, "-m", "lib_py.layout", "--json"],
@@ -223,7 +228,7 @@ def test_cli_invocation_pipe():
assert res_json.returncode == 0
data = json.loads(res_json.stdout)
assert data["target_pane_id"] == "p1"
assert data["direction"] == "down"
assert data["direction"] == "right"
assert not data["is_overflow"]
@@ -284,10 +289,10 @@ _real_herdr() {{
sample_pane="p1"
split_dir=""
# Exact snippet from lib.sh:429-435
# Exact snippet from lib.sh layout invocation
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:-15}}" --min-rows "${{MAM_MIN_PANE_ROWS:-0}}" --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}}" --max-cols "${{MAM_MAX_PANE_COLS:-2}}" --max-rows "${{MAM_MAX_PANE_ROWS:-2}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${{split_dir:-right}}"
sample_pane="${{split_target:-$sample_pane}}"
fi
@@ -297,7 +302,7 @@ echo "SAMPLE_PANE=$sample_pane"
"""
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
assert res.returncode == 0, f"Script failed with code {res.returncode}. Stderr: {res.stderr}"
assert "SPLIT_DIR=down" in res.stdout
assert "SPLIT_DIR=right" in res.stdout
assert "SAMPLE_PANE=p1" in res.stdout
@@ -360,12 +365,11 @@ def test_cli_max_cols_flag_triggers_overflow():
assert res.returncode == 0, res.stderr
d = json.loads(res.stdout)
assert d["direction"] == "overflow" and d["is_overflow"]
assert d["reason"] == "max_columns_reached"
assert d["reason"] == "grid_capacity_reached"
def test_env_max_cols_applies_without_flag():
"""MAM_MAX_PANE_COLS is honoured with no --max-cols flag, which is exactly
how lib.sh invokes the module (lib.sh passes no --max-cols)."""
"""MAM_MAX_PANE_COLS is honoured with no --max-cols flag."""
payload = json.dumps(_four_panes_two_columns())
skills_dir = os.path.abspath(".agents/skills")
env = {**os.environ, "PYTHONPATH": skills_dir, "MAM_MAX_PANE_COLS": "2"}
@@ -374,48 +378,37 @@ def test_env_max_cols_applies_without_flag():
"--min-cols", "30", "--min-rows", "20", "--json"],
input=payload, capture_output=True, text=True, env=env)
assert res.returncode == 0, res.stderr
assert json.loads(res.stdout)["reason"] == "max_columns_reached"
assert json.loads(res.stdout)["reason"] == "grid_capacity_reached"
def _headless(n):
return {"result": {"panes": [
{"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
for i in range(1, n + 1)]}}
def test_headless_max_columns_growth_guard():
"""C-1: headless mode must honour max_columns too.
A headless 2xK grid completes n // 2 columns, so at n=4 with max_columns=2
a further `right` split would open a third column and must overflow instead.
Note the cap blocks *opening* a new column; it does not force an existing
over-cap layout to shrink -- the odd-n `down` branch (and the GUI's
fill_singleton_column) deliberately ignore it.
"""
def headless(n):
return {"result": {"panes": [
{"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
for i in range(1, n + 1)]}}
d4 = compute_2xk_layout(headless(4), max_columns=2)
"""Headless capacity: n=1 right, n=2/3 down, n>=4 overflow at max_columns=2, max_rows=2."""
d4 = compute_2xk_layout(_headless(4), max_columns=2, max_rows=2)
assert d4.is_overflow and d4.direction == "overflow"
assert d4.reason == "max_columns_reached"
assert d4.reason == "grid_capacity_reached"
# Continues growing below the cap
d2 = compute_2xk_layout(headless(2), max_columns=2)
assert d2.direction == "right" and not d2.is_overflow
d2 = compute_2xk_layout(_headless(2), max_columns=2, max_rows=2)
assert d2.direction == "down" and d2.target_pane_id == "p1" and not d2.is_overflow
# Filling an existing column is not blocked (mirrors GUI fill_singleton_column)
d3 = compute_2xk_layout(headless(3), max_columns=2)
assert d3.direction == "down" and not d3.is_overflow
d3 = compute_2xk_layout(_headless(3), max_columns=2, max_rows=2)
assert d3.direction == "down" and d3.target_pane_id == "p2" and not d3.is_overflow
# n=5 is the first odd n that can discriminate: n//2 == 2 == max_columns, so an
# over-correction that also checked the cap on the odd branch would return
# overflow here. n=3 has n//2 == 1 and cannot reach the check at all.
d5 = compute_2xk_layout(headless(5), max_columns=2)
assert d5.direction == "down" and not d5.is_overflow
assert d5.reason == "headless_odd_down"
d5 = compute_2xk_layout(_headless(5), max_columns=2, max_rows=2)
assert d5.is_overflow and d5.reason == "grid_capacity_reached"
# When max_columns is not set, existing alternation is preserved (behavior neutrality)
assert compute_2xk_layout(headless(4)).direction == "right"
# Default cap is 2 even when the caller omits max_columns.
assert compute_2xk_layout(_headless(4)).direction == "overflow"
_LAYOUT_ENV_VARS = ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", "MAM_MIN_ROWS",
"MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS")
"MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS",
"MAM_MAX_ROWS", "MAM_MAX_PANE_ROWS")
def _run_layout(payload, args=(), env_extra=None):
@@ -437,14 +430,14 @@ _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 15 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20"))
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
assert flag["direction"] == "right" and flag["reason"] == "new_column_right"
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {var: "0"}) == flag, var
def test_j1_env_zero_min_rows_matches_flag_zero():
flag = _run_layout(_ZERO_TRAP, ("--min-rows", "0"))
assert flag["direction"] == "down" and flag["reason"] == "single_pane_split_down"
assert flag["direction"] == "right" and flag["reason"] == "new_column_right"
for var in ("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
@@ -480,26 +473,17 @@ def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
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)
"""Default min_cols=15, min_rows=0. N=1 opens a column with right."""
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 d1.direction == "right"
assert not d1.is_overflow
assert d1.reason == "new_column_right"
# 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": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 40}},
]
}
}
@@ -510,15 +494,11 @@ def test_default_min_cols_is_15_and_min_rows_is_0():
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.
"""
# 30 cols: 30 // 2 = 15 == min_cols(15) -> splits right
"""N=1: width >= 30 allows a right split (min_cols=15); 29 overflows."""
payload_30 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 40}},
]
}
}
@@ -526,12 +506,10 @@ def test_30_col_2_column_splitting_boundary():
assert d30.direction == "right"
assert not d30.is_overflow
# 29 cols: 29 // 2 = 14 < min_cols(15) -> overflow
payload_29 = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}},
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 40}},
]
}
}
@@ -542,51 +520,155 @@ def test_30_col_2_column_splitting_boundary():
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 (splits down)
"""1→2 right, 2→3 down left, 3→4 down right, 4→5 overflow at capacity."""
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.direction == "right"
assert d1.target_pane_id == "p1"
assert not d1.is_overflow
# 2 -> 3 (splits right)
p2_layout = {"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}},
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 23}},
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}},
]}}
d2 = compute_2xk_layout(p2_layout)
assert d2.direction == "right"
assert d2.direction == "down"
assert d2.target_pane_id == "p1"
assert not d2.is_overflow
# 3 -> 4 (fills singleton col 2 down)
p3_layout = {"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}},
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}},
{"pane_id": "p3", "rect": {"x": 26, "y": 12, "width": 27, "height": 12}},
]}}
d3 = compute_2xk_layout(p3_layout)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert d3.target_pane_id == "p2"
assert not d3.is_overflow
# 4 -> 5 (overflow to new workspace because 27 // 2 = 13 < 15)
p4_layout = {"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}},
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 26, "y": 12, "width": 27, "height": 12}},
{"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 12}},
]}}
d4 = compute_2xk_layout(p4_layout)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"
assert d4.reason == "grid_capacity_reached"
def _payload(panes):
return {"result": {"panes": [
{"pane_id": p["id"], "rect": {"x": p["x"], "y": p["y"], "width": p["w"], "height": p["h"]}}
for p in panes
]}}
def _bsp_split(panes, tid, direction):
"""herdr contract: bisect only the target pane's rect."""
out = []
next_id = f"p{len(panes) + 1}"
for p in panes:
if p["id"] != tid:
out.append(dict(p))
continue
if direction == "right":
w1 = p["w"] // 2
w2 = p["w"] - w1
out.append({**p, "w": w1})
out.append({"id": next_id, "x": p["x"] + w1, "y": p["y"], "w": w2, "h": p["h"]})
elif direction == "down":
h1 = p["h"] // 2
h2 = p["h"] - h1
out.append({**p, "h": h1})
out.append({"id": next_id, "x": p["x"], "y": p["y"] + h1, "w": p["w"], "h": h2})
else:
raise AssertionError(f"unexpected split {direction}")
return out
def test_four_panes_form_clean_2x2():
panes = [{"id": "p1", "x": 0, "y": 0, "w": 277, "h": 78}]
for _ in range(3):
d = compute_2xk_layout(_payload(panes))
assert not d.is_overflow, d
panes = _bsp_split(panes, d.target_pane_id, d.direction)
assert len(panes) == 4
assert max(p["w"] for p in panes) - min(p["w"] for p in panes) <= 2
assert max(p["h"] for p in panes) - min(p["h"] for p in panes) <= 2
def test_fifth_pane_overflows():
panes = [{"id": "p1", "x": 0, "y": 0, "w": 277, "h": 78}]
for _ in range(3):
d = compute_2xk_layout(_payload(panes))
assert not d.is_overflow
panes = _bsp_split(panes, d.target_pane_id, d.direction)
d = compute_2xk_layout(_payload(panes))
assert d.is_overflow and d.direction == "overflow"
assert d.reason == "grid_capacity_reached"
@pytest.mark.parametrize("n,expected", [(1, "right"), (2, "down"), (3, "down"), (4, "overflow")])
def test_headless_matches_gui_decision(n, expected):
hl = compute_2xk_layout(_headless(n), max_columns=2, max_rows=2)
assert hl.direction == expected, f"headless n={n}"
def test_headless_gui_direction_parity_full_sequence():
panes = [{"id": "p1", "x": 0, "y": 0, "w": 277, "h": 78}]
for n in range(1, 5):
gui = compute_2xk_layout(_payload(panes), max_columns=2, max_rows=2)
hl = compute_2xk_layout(_headless(n), max_columns=2, max_rows=2)
assert gui.direction == hl.direction, f"divergence at n={n}"
if gui.is_overflow:
break
panes = _bsp_split(panes, gui.target_pane_id, gui.direction)
def test_headless_fills_alternating_columns():
assert compute_2xk_layout(_headless(2), max_columns=2, max_rows=2).target_pane_id == "p1"
assert compute_2xk_layout(_headless(3), max_columns=2, max_rows=2).target_pane_id == "p2"
def test_headless_ignores_sample_pane_anchor():
d = compute_2xk_layout(_headless(3), max_columns=2, max_rows=2, default_anchor_id="p1")
assert d.target_pane_id == "p2"
def test_never_splits_right_on_partial_height_pane():
payload = {
"result": {
"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 277, "height": 39}},
{"pane_id": "p2", "rect": {"x": 0, "y": 39, "width": 277, "height": 39}},
]
}
}
d = compute_2xk_layout(payload, max_columns=2, max_rows=2)
assert d.direction != "right"
def test_max_cols_default_reaches_cli_path():
payload = json.dumps(_four_panes_two_columns())
skills_dir = os.path.abspath(".agents/skills")
env = {**os.environ, "PYTHONPATH": skills_dir}
for k in _LAYOUT_ENV_VARS:
env.pop(k, None)
res = subprocess.run(
[sys.executable, "-m", "lib_py.layout", "--json"],
input=payload, capture_output=True, text=True, env=env)
assert res.returncode == 0, res.stderr
d = json.loads(res.stdout)
assert d["direction"] == "overflow"
assert d["reason"] == "grid_capacity_reached"
def test_lib_sh_passes_max_cols_and_rows():
lib_path = os.path.abspath(".agents/skills/lib.sh")
content = open(lib_path, encoding="utf-8").read()
assert '--max-cols "${MAM_MAX_PANE_COLS:-2}"' in content
assert '--max-rows "${MAM_MAX_PANE_ROWS:-2}"' in content
+13 -21
View File
@@ -872,12 +872,10 @@ 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 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": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}}
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 40}},
]
}
}
@@ -886,12 +884,10 @@ def test_layout_default_min_cols_15_in_tier1():
assert not decision.is_overflow
assert decision.reason == "new_column_right"
# 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": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}}
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 40}},
]
}
}
@@ -905,45 +901,41 @@ 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
# 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.direction == "right"
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}}
{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 23}},
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}}
]}}
d2 = compute_2xk_layout(p2)
assert d2.direction == "right"
assert d2.direction == "down"
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}}
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}},
{"pane_id": "p3", "rect": {"x": 26, "y": 12, "width": 27, "height": 12}}
]}}
d3 = compute_2xk_layout(p3)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert d3.target_pane_id == "p2"
assert not d3.is_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}}
{"pane_id": "p2", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 26, "y": 12, "width": 27, "height": 12}},
{"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 12}}
]}}
d4 = compute_2xk_layout(p4)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"
assert d4.reason == "grid_capacity_reached"
# ==============================================================================