feat(layout,test): resolve backlog items I-2, I-3, and C-1 with unanimous peer review
- I-2: add 5.0s upper-bound execution time assertion in test_bug4_headless_unobservable_fast_path to contractually guard SKS_EMPTY_GIVEUP early-exit latency - I-3: clean up PaneInfo.focused, wire MAM_MAX_PANE_COLS/MAM_MAX_COLS environment variables and CLI --max-cols flag - C-1: apply max_columns growth guard to headless 0x0 layouts, mirroring GUI behavior - Promoted Planner consensus plan Rev.2 (plan-fea5f1b2.md) and unanimous PASS reports from Reviewers Claude (report-55d1a1d9.md) and Cline (report-6f18ba0f.md) - Verified all 333 test cases pass with exit code 0
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
Shared 2xK grid TUI layout engine for multi-agent workspaces.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
from typing import List, Dict, Optional, Any
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
@@ -17,7 +17,10 @@ class PaneInfo:
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
focused: bool = False
|
||||
# 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
|
||||
@@ -28,10 +31,14 @@ class LayoutDecision:
|
||||
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."""
|
||||
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 [], None
|
||||
return []
|
||||
|
||||
res = data.get("result", {})
|
||||
if not isinstance(res, dict):
|
||||
@@ -40,10 +47,8 @@ def extract_panes_and_focus(data: Dict[str, Any]) -> Tuple[List[PaneInfo], Optio
|
||||
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:
|
||||
@@ -57,11 +62,10 @@ def extract_panes_and_focus(data: Dict[str, Any]) -> Tuple[List[PaneInfo], Optio
|
||||
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))
|
||||
panes.append(PaneInfo(pane_id=pid, x=x, y=y, width=w, height=h))
|
||||
|
||||
return panes, focused_id
|
||||
return panes
|
||||
|
||||
|
||||
def compute_2xk_layout(
|
||||
@@ -75,9 +79,10 @@ def compute_2xk_layout(
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -99,15 +104,25 @@ def compute_2xk_layout(
|
||||
# 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
|
||||
# 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 # Most recent or last pane
|
||||
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")
|
||||
else:
|
||||
return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
|
||||
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)
|
||||
@@ -157,11 +172,25 @@ def compute_2xk_layout(
|
||||
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="right", reason="new_column_right")
|
||||
|
||||
|
||||
def _env_int(*names: str) -> Optional[int]:
|
||||
"""First non-empty env var among *names, parsed as int. Bad values are
|
||||
ignored rather than raised: a typo in an operator's shell must not take the
|
||||
whole layout call down (lib.sh would silently fall back to 'right')."""
|
||||
for n in names:
|
||||
raw = os.environ.get(n, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
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("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS") or 60)
|
||||
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS") or 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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user