diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 9dfc327..980ee89 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -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 diff --git a/.agents/skills/lib_py/layout.py b/.agents/skills/lib_py/layout.py index 6f4697f..439ff67 100644 --- a/.agents/skills/lib_py/layout.py +++ b/.agents/skills/lib_py/layout.py @@ -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 ) diff --git a/.mam.env.example b/.mam.env.example index ed4c823..6e39ca4 100644 --- a/.mam.env.example +++ b/.mam.env.example @@ -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) diff --git a/tests/test_b19_headless_reconcile_fixes.py b/tests/test_b19_headless_reconcile_fixes.py index d79008e..ae7903b 100644 --- a/tests/test_b19_headless_reconcile_fixes.py +++ b/tests/test_b19_headless_reconcile_fixes.py @@ -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(): diff --git a/tests/test_layout.py b/tests/test_layout.py index f2818a9..857dc56 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -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 + diff --git a/tests/test_tier1_unit.py b/tests/test_tier1_unit.py index ce99e53..0c5802e 100644 --- a/tests/test_tier1_unit.py +++ b/tests/test_tier1_unit.py @@ -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" # ==============================================================================