Files
multi-agent-mux/.agents/skills/lib_py/layout.py
T
Godopu 31b2d70ffe fix(lib,reconcile,test): address Claude review findings and verify 100% PASS
- lib.sh: distinguish unobservable/headless (rc=2) from unsettled panes in _pane_quiescent; restore 10s quiescence window with early giveup (SKS_EMPTY_GIVEUP)
- reconcile.sh: fix SKILLS_DIR command substitution logic (&& pwd instead of || pwd)
- tests/test_b19_headless_reconcile_fixes.py: implement mutation-proven regression tests for headless prompt bypass, slow-settling panes with persistent counters, and real reconcile.sh SKILLS_DIR evaluation
- deploy/ & IMPROVEMENTS.md: document nats submodule access notes and B-19/B-20 evolution
- Promoted Reviewer Claude's final 100% PASS report (report-119b9f57.md)
2026-08-23 20:57:38 +09:00

202 lines
7.4 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, 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")
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:
# 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()