feat(layout): implement right-growth 2xK grid layout engine in lib_py.layout and refactor lib.sh (B-20)
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
.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, Tuple
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaneInfo:
|
||||
pane_id: str
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
focused: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayoutDecision:
|
||||
target_pane_id: str
|
||||
direction: str # 'right' | 'down' | 'overflow'
|
||||
is_overflow: bool = False
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def extract_panes_and_focus(data: Dict[str, Any]) -> Tuple[List[PaneInfo], Optional[str]]:
|
||||
"""Extracts list of PaneInfo and focused_pane_id from herdr layout JSON payload."""
|
||||
if not isinstance(data, dict):
|
||||
return [], None
|
||||
|
||||
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", [])
|
||||
focused_id = layout.get("focused_pane_id") or res.get("focused_pane_id")
|
||||
else:
|
||||
raw_panes = res.get("panes", []) or data.get("panes", [])
|
||||
focused_id = res.get("focused_pane_id") or data.get("focused_pane_id")
|
||||
|
||||
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))
|
||||
focused = bool(p.get("focused", False) or (pid and pid == focused_id))
|
||||
if pid:
|
||||
panes.append(PaneInfo(pane_id=pid, x=x, y=y, width=w, height=h, focused=focused))
|
||||
|
||||
return panes, focused_id
|
||||
|
||||
|
||||
def compute_2xk_layout(
|
||||
data: Dict[str, Any],
|
||||
min_cols: int = 60,
|
||||
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, focused_id = extract_panes_and_focus(data)
|
||||
|
||||
if not panes:
|
||||
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")
|
||||
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:
|
||||
# Alternation based on count N
|
||||
# N=1 -> down (2), N=2 -> right (3), N=3 -> down (4), N=4 -> right (5)...
|
||||
# Odd N -> split down; Even N -> split right
|
||||
n = len(panes)
|
||||
anchor = default_anchor_id or panes[-1].pane_id # Most recent or last pane
|
||||
if n % 2 == 1:
|
||||
return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
|
||||
else:
|
||||
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 main():
|
||||
parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction")
|
||||
parser.add_argument("--min-cols", type=int, default=int(os.environ.get("MAM_MIN_COLS", os.environ.get("MAM_MIN_PANE_COLS", 60))))
|
||||
parser.add_argument("--min-rows", type=int, default=int(os.environ.get("MAM_MIN_ROWS", os.environ.get("MAM_MIN_PANE_ROWS", 20))))
|
||||
parser.add_argument("--max-cols", type=int, default=None)
|
||||
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()
|
||||
Reference in New Issue
Block a user