- 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
275 lines
8.7 KiB
Python
275 lines
8.7 KiB
Python
"""
|
|
.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 _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] = 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.
|
|
"""
|
|
if max_columns is None:
|
|
max_columns = 2
|
|
if max_rows is None:
|
|
max_rows = 2
|
|
|
|
panes = extract_panes(data)
|
|
|
|
if not panes:
|
|
return _right("no_panes_default", default_anchor_id or "")
|
|
|
|
if all(p.width <= 0 or p.height <= 0 for p in panes):
|
|
return _decide_headless(panes, max_columns, max_rows)
|
|
|
|
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]:
|
|
"""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 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
|
|
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=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", 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")
|
|
|
|
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,
|
|
max_rows=args.max_rows,
|
|
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()
|