""" .agents/skills/lib_py/layout.py Shared 2xK grid TUI layout engine for multi-agent workspaces. """ from dataclasses import dataclass from typing import List, Dict, Optional, Any import json import sys import os import argparse @dataclass class PaneInfo: pane_id: str x: int y: int width: int height: int # NOTE: no `focused` field. The 2xK engine is deliberately geometry- and # structure-driven so that identical pane sets always yield identical # decisions. Focus is user-interaction state and would make the result # non-deterministic; herdr still reports it in the payload if ever needed. @dataclass class LayoutDecision: target_pane_id: str direction: str # 'right' | 'down' | 'overflow' is_overflow: bool = False reason: str = "" def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]: """Extracts list of PaneInfo from herdr layout JSON payload. Accepts all three shapes herdr 0.8 emits: result.layout.panes, result.panes, and a bare top-level panes array. """ if not isinstance(data, dict): return [] res = data.get("result", {}) if not isinstance(res, dict): res = {} layout = res.get("layout", {}) if isinstance(layout, dict) and "panes" in layout: raw_panes = layout.get("panes", []) else: raw_panes = res.get("panes", []) or data.get("panes", []) panes: List[PaneInfo] = [] for p in raw_panes: if not isinstance(p, dict): continue pid = str(p.get("pane_id", "")) rect = p.get("rect", {}) if not isinstance(rect, dict): rect = {} x = int(rect.get("x", 0)) y = int(rect.get("y", 0)) w = int(rect.get("width", 0)) h = int(rect.get("height", 0)) if pid: panes.append(PaneInfo(pane_id=pid, x=x, y=y, width=w, height=h)) return panes def compute_2xk_layout( data: Dict[str, Any], min_cols: int = 40, min_rows: int = 20, max_columns: Optional[int] = None, default_anchor_id: Optional[str] = None ) -> LayoutDecision: """ Computes optimal target pane and direction to maintain a balanced 2xK grid. Only uses Herdr-supported split directions: 'right' and 'down'. """ 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") # 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 if dimensions known if 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") # 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 if 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") 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). 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 would silently fall back to 'right'), and must not shadow a later candidate that IS set correctly -- MAM_MIN_COLS is a legacy alias while MAM_MIN_PANE_COLS is the name .mam.env.example documents, so aborting on the first bad value would discard the documented setting. Empty values already fell through; this makes invalid values behave the same way. """ for n in names: raw = os.environ.get(n, "").strip() if raw: try: return int(raw) except ValueError: continue return default 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("--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") args = parser.parse_args() raw_input = sys.stdin.read().strip() data = {} if raw_input: try: data = json.loads(raw_input) except Exception: data = {} decision = compute_2xk_layout( data=data, min_cols=args.min_cols, min_rows=args.min_rows, max_columns=args.max_cols, default_anchor_id=args.sample_pane ) if args.json: print(json.dumps({ "target_pane_id": decision.target_pane_id, "direction": decision.direction, "is_overflow": decision.is_overflow, "reason": decision.reason })) else: if decision.target_pane_id: print(f"{decision.direction} {decision.target_pane_id}") else: print(decision.direction) if __name__ == "__main__": main()