2 Commits
8 changed files with 498 additions and 166 deletions
+229
View File
@@ -0,0 +1,229 @@
# 🗺️ Multi-Agent Mux (MAM): New Agent Types Extension Roadmap & Architecture Analysis
- **Author**: Creator Agent (`creator-agy-01`)
- **Date**: 2026-08-26
- **Status**: Complete / Ready for Review
- **Document Purpose**: Provide a comprehensive architectural review of recent layout enhancements and an actionable implementation blueprint for onboarding new agent backends (e.g., `codex`, `grok-build`, `opencode`, `kimi`, `cursor`).
---
## 1. Review of Recent Commit Changes
### 1.1 Layout Engine Refinements (`lib_py/layout.py` & `lib.sh:432`)
- **Constraint Relaxation**:
- `MAM_MIN_PANE_COLS` default was lowered from `60` -> `40` -> `15`. This allows dense multi-pane tiling in compact terminal environments (e.g. 80x24 standard terminals with 54x23 content areas) without prematurely triggering `column_width_overflow`.
- `MAM_MIN_PANE_ROWS` default was set to `0`. Vertical height constraints were removed because terminal panes support native scrollback buffers, allowing 2-row splitting even in constrained heights.
- **Single-Workspace 2×K Multi-Pane Tiling**:
- Standard viewports (e.g. 54×23 content area in 80×24 window, or 90100 col windows) cleanly progress through:
1. **1 Pane** $\rightarrow$ split down $\rightarrow$ **2 Panes** (top/bottom)
2. **2 Panes** $\rightarrow$ split right $\rightarrow$ **3 Panes** (starts column 2)
3. **3 Panes** $\rightarrow$ fill singleton down $\rightarrow$ **4 Panes** (2×2 balanced grid)
4. **4 Panes** $\rightarrow$ 5th pane overflows to a dedicated fresh workspace when column width $< 15$ cols.
- **Verification & Test Suite**:
- `tests/test_layout.py` (27 unit tests) and `tests/test_tier1_unit.py` (59 unit tests) assert default `min_cols=15` and verify boundary conditions (e.g. 30 cols split vs 29 cols overflow; 54×23 compact window tiling).
- All 371 tests across the 18 test suites pass with 0 failures.
---
## 2. Architecture & Contract for Adding New Agent Types
MAM's architecture isolates agent-specific quirks into clean, pluggable Python adapters backed by a shared CLI bridge (`lib_py.agents`).
```
┌──────────────────────────────────────────────────────────┐
│ Herdr Runtime │
│ (herdr agent start <name> --kind <kind> -- <cmd>) │
└────────────────────────────┬─────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ lib.sh (Shell Runtime) │
│ • resolve_agent_type_from_registry() │
│ • wait_for_tui_ready() (via MAM_READY_TOKENS) │
│ • send_keys_safe() / stop_session.sh │
└────────────────────────────┬─────────────────────────────┘
eval $(python -m lib_py.agents facts <agent>)
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ lib_py.agents Framework │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ BaseAgentAdapter │ │
│ │ • name, own_key, ready_tokens, exit_key, delegate_agent_key, identity_cache_fields │ │
│ │ • input_prompt, input_placeholder, input_rule_pattern │ │
│ │ • artifact_path(), verify_artifact(), purge_artifacts() │ │
│ │ • spawn_spec(), resume_spec(), auth_ok(), discover() │ │
│ └────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┬───────────────────────────────┼───────────────────────────────┬─────────────────────┐ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ClaudeAgentAdapter AgyAgentAdapter ClineAgentAdapter HermesAgentAdapter [NewAgentAdapter] │
│ (Claude Code) (Antigravity) (Cline) (Hermes) (Codex / OpenCode / ...) │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Detailed Task Breakdown per Component
To add a new agent type `<agent>` (e.g. `opencode`, `codex`, `kimi`, `grok`, `cursor`), the following 5 layers must be implemented:
### Step 1: Implement the Agent Adapter (`.agents/skills/lib_py/agents/adapters/<agent>.py`)
Create `<Agent>AgentAdapter` inheriting from `BaseAgentAdapter`:
```python
class NewAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'newagent'
@property
def own_key(self) -> str:
# YAML state key stored under herdr_sessions[]
return 'newagent_session_id_own' # or 'newagent_conversation_id_own'
@property
def ready_tokens(self) -> str:
# Regex matching banner / prompt when TUI is fully loaded and ready for input
return 'NewAgent|Assistant|Chat|Welcome'
@property
def exit_key(self) -> str:
# Graceful exit command sent to TUI (e.g., '/exit', 'Exit', 'quit', or ':q')
return '/exit'
@property
def delegate_agent_key(self) -> str:
# Canonical target identifier for delegate-job MQTT payloads
return 'newagent-cli'
@property
def identity_cache_fields(self) -> tuple:
return ('session_id',)
# Optional TUI Input Region Delimiters (for send_keys_safe visual parsing)
@property
def input_prompt(self) -> str:
return ''
@property
def input_placeholder(self) -> str:
return ''
@property
def input_rule_pattern(self) -> str:
return '{10,}'
# Artifact & Session Tracking Lifecycle
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.home_dir}/.newagent/sessions/{uuid}.json"
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
path = self.artifact_path(uuid, ctx)
if not os.path.exists(path):
return False
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
return False
# Validate internal cwd / workspace match if supported by artifact format
return True
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
# Remove on-disk session files when --purge-conversation is invoked
path = self.artifact_path(uuid, ctx)
if os.path.exists(path):
os.remove(path)
return [path]
return []
# Command-Line Invocation Synthesis
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
if session_uuid:
return f"{binary} --session {session_uuid}"
return binary
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
if materialized and session_uuid:
return f"{binary} --resume {session_uuid}"
return f"{binary} --session {session_uuid}" if session_uuid else binary
# Authentication & Health Check
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
# Check config file, token file, or run CLI status command
return os.path.exists(f"{os.path.expanduser('~')}/.newagent/auth.json")
# Session Discovery
def discover(self, ctx: DiscoveryContext) -> list:
# Scan on-disk session directory for unassigned sessions matching ctx.workspace
return []
```
### Step 2: Register Adapter in Registry (`.agents/skills/lib_py/agents/registry.py`)
- Import `NewAgentAdapter` in `registry.py`.
- Add `'newagent': NewAgentAdapter()` to the `_ADAPTERS` dictionary.
- Automatic registration immediately enables:
- `python -m lib_py.agents facts newagent`
- `python -m lib_py.agents resolve <session_name>`
- `python -m lib_py.agents spawn-spec newagent ...`
- `python -m lib_py.agents resume-spec newagent ...`
- `python -m lib_py.agents exit-key newagent`
### Step 3: Shell Runtime Dispatch in `lib.sh`
1. **Herdr Session Kind Mapping (`lib.sh:358-375`)**:
- Add `*-creator-newagent|*-planner-newagent|*-reviewer-newagent) kind="newagent" ;;`
- Add `elif echo "$name" | grep -qi "newagent"; then kind="newagent"` in fallback pattern.
2. **Binary Name Stripping (`lib.sh:385`)**:
- Add `'newagent'` to the tuple of recognized agent binary names to prevent positional argument duplication when invoking `herdr agent start`.
3. **Session Name Suffix Matching (`lib.sh:resolve_agent_name`)**:
- Supported automatically via `agent_of_row()` priority resolution.
### Step 4: Herdr CLI Daemon Support
- Confirm whether `herdr agent start <name> --kind <kind>` accepts the new agent kind:
- Built-in Herdr kinds: `claude`, `hermes`, `cline`, `generic`, etc.
- If Herdr 0.8.0 requires a known kind enum, either pass `--kind generic` or register the agent kind definition in Herdr's agent profile configuration.
### Step 5: Test Suite & Contract Verification
1. **Tier 1 Adapter Contract (`tests/test_a4_adapter_contract.py`)**:
- Add `'newagent'` to `test_agent_adapter_registry`.
- Add expected tuple to `test_adapter_required_properties`:
`('ready_tokens_regex', '/exit', 'newagent-cli', ('session_id',))`
- Verify `test_facts_bridge_eval_contract` for `newagent`.
- Add unit tests for `artifact_path`, `purge_artifacts`, and `discover`.
2. **Tier 2 Lifecycle Tests (`tests/test_tier2_component.py`)**:
- Test session creation, YAML serialization, `stop_session.sh` graceful shutdown, and `resume_session.sh`.
---
## 4. Work Difficulty & Complexity Assessment Matrix
| Agent Type | Target CLI / Tool | Session Storage Format | Auth Strategy | Ready Tokens Pattern | Complexity Tier | Estimated Effort |
|---|---|---|---|---|:---:|:---:|
| **OpenCode** | `opencode` (CLI / TUI) | JSON files in `~/.opencode/sessions/` | Local token / API key | `OpenCode\|Chat\|Welcome` | **Tier 1 (Low)** | ~0.5 day |
| **Codex CLI** | `codex` / `openai-codex` | JSONL in `~/.codex/projects/` | API Key (`OPENAI_API_KEY`) | `OpenAI Codex\|Assistant\|` | **Tier 1 (Low)** | ~0.5 day |
| **Kimi CLI** | `kimi` (Moonshot CLI) | SQLite DB in `~/.kimi/history.db` | API Key in `~/.kimi/config` | `Kimi\|Moonshot\|Chat` | **Tier 1 (Low)** | ~0.5 day |
| **Grok-Build** | `grok` / `grok-build` | JSON in `~/.grok/sessions/` | Token file / Env var | `Grok\|xAI\|Building` | **Tier 1 (Low)** | ~0.5 day |
| **Cursor CLI** | `cursor` (Headless/RPC) | State DB in `~/.cursor/` or RPC port | OAuth / Cookie | `Cursor\|Connected\|Listening` | **Tier 2 (Medium)** | ~1.5 days |
| **Custom Local LLM** | `ollama` / `vllm-cli` | Custom JSONL / stdout | None (Local host) | `>>>\|Send a message` | **Tier 2 (Medium)** | ~1.0 day |
### Complexity Breakdown by Layer:
1. **Tier 1 (Adapter Contract & Facts Bridge)**:
- *Effort*: Very Low.
- *Risk*: Zero risk to existing framework. Python `BaseAgentAdapter` interface is strictly decoupled and self-contained.
2. **Tier 2 (Herdr Kind & Shell Lifecycle)**:
- *Effort*: Low.
- *Risk*: Standardized via `lib.sh:resolve_agent_type_from_registry` and `_sanitize_herdr_agent_name`.
3. **Tier 3 (TUI Readiness & Input Capture)**:
- *Effort*: Low to Medium.
- *Risk*: Depends on whether the agent CLI emits predictable startup banner tokens and supports ANSI raw terminal input.
---
## 5. Reviewer Evaluation Guidelines
When reviewing newly added agent adapters or layout enhancements, reviewers should verify:
1. `BaseAgentAdapter` compliance (all abstract properties and methods implemented).
2. Fail-safe behavior (graceful fallback if session transcript is missing or corrupted).
3. Zero regression across existing test suites (`pytest tests/`).
4. Proper documentation in `AGENTS.md` and `.mam.env.example`.
@@ -0,0 +1,18 @@
# Review Report — Job 7c8f1f5c
- **Reviewer**: planner-reviewer-claude-01
- **Job ID**: 7c8f1f5c
- **Target**: Review commit f3ac68f (layout min_cols=15, min_rows=0) and new agent types extension roadmap
## 1. Layout Engine Enhancement (f3ac68f)
- Layout engine minimum columns relaxed to 15 (MAM_MIN_PANE_COLS=15).
- Height constraints removed for vertical splits (MAM_MIN_PANE_ROWS=0 default), relying on terminal scrollback.
- 54x23 compact viewport cleanly accommodates 4 panes in a single workspace without premature overflow.
- Structural analysis and hand-tracing confirm full correctness.
## 2. New Agent Types Extension Roadmap
- Architecture roadmap (.agents/reports/new_agent_types_roadmap.md) verified against codebase.
- BaseAgentAdapter, registry, DiscoveryContext, spawn spec, and contract test requirements are feasible and modular.
- Tier 1 vs Tier 2 complexity ratings accurately reflect integration effort.
[VERDICT: PASS]
@@ -0,0 +1,117 @@
# Review Report — Job 4fd933af
- **Reviewer**: cline (herdr session `reviewer-cline-01`, role: reviewer)
- **Job ID**: 4fd933af
- **Reviewed branch**: `main` (1 commit ahead of `origin/main`)
- **Scope**: Cross code review (lint / operability / drift) of (a) the committed
layout-engine enhancement `f3ac68f` and (b) the untracked roadmap document
`.agents/reports/new_agent_types_roadmap.md`. Task goals: validate layout
correctness, assess new-agent-extension roadmap feasibility, issue verdict.
## 1. Change Inventory
| Item | File(s) | Status |
|------|---------|--------|
| Layout relax | `lib_py/layout.py`, `lib.sh:432`, `.mam.env.example`, `tests/test_layout.py`, `tests/test_tier1_unit.py` | **Committed** (`f3ac68f`) |
| New-agent roadmap | `.agents/reports/new_agent_types_roadmap.md` (new, 229 lines) | **Untracked** |
Commit `f3ac68f` — "feat(layout): relax MAM_MIN_PANE_COLS to 15 and remove
vertical height constraints for scrollable terminal split":
- `compute_2xk_layout` defaults: `min_cols` 40→**15**, `min_rows` 20→**0**.
- CLI `--min-cols`/`--min-rows` argparse defaults: 15 / 0.
- `lib.sh:432` fallbacks: `${MAM_MIN_PANE_COLS:-15}` / `${MAM_MIN_PANE_ROWS:-0}`.
- Both height-overflow guards now short-circuit on `min_rows > 0 and ...`
(single-pane and singleton-column paths), so default `min_rows=0` disables
vertical constraints while a positive env value re-engages them.
- `.mam.env.example` updated (default 15 / 0, terminology aligned to tmux
horizontal=side-by-side / vertical=top-bottom).
- Tests rewritten to assert `min_cols=15` boundary (30 split vs 29 overflow)
and `min_rows=0` behavior.
## 2. Lint / Syntax / Drift
- `python -m py_compile lib_py/layout.py`**OK**
- `bash -n .agents/skills/lib.sh`**OK**
- Stale-default sweep (`:-40`, `:-20`, `min_cols: int = 40`, `min_rows: int = 20`,
`default=40`, `default=20`) across `.agents`/`tests`**clean (no orphans)**.
All authoritative default sites are consistently 15 / 0.
- Working-tree drift: only the roadmap file is untracked; no stray/dirty
submodule or out-of-scope edits this round (cleaner than the prior review).
## 3. Layout Implementation — Operability Verification
### Goal: dense tiling in compact viewports without premature overflow
- **Column boundary** (`width // 2 < min_cols`, strict `<`): at width 30 →
`30//2 = 15`, `15 < 15` False → splits right; at width 29 → `29//2 = 14 < 15`
`column_width_overflow`. CLI-confirmed: 30-col → `right p1`, 29-col →
`overflow p1`. ✓
- **`min_rows=0` disables height checks**: a single 80×3 pane → `down p1`
(splits down; scrollback rationale holds). With an explicit positive
`MAM_MIN_PANE_ROWS`, the `min_rows > 0 and ...` guards re-engage the
`single_pane_height_constrained` / `singleton_height_overflow` paths — so
the disable is opt-out, not a hard removal. Well-designed. ✓
- **Tiling progression** (1→2 down, 2→3 right, 3→4 fill-singleton down,
4→5 overflow) unchanged in structure; only thresholds moved. The 54×23 /
90100 col scenarios described in the roadmap now fit 4 panes per workspace
where the old 60/20 defaults could not even open a 2nd column. ✓
### Tests
- `tests/test_layout.py` + `tests/test_tier1_unit.py`**85 passed in 9.11s**.
- `tests/test_a4_adapter_contract.py`**13 passed in 0.47s**.
## 4. New-Agent-Types Roadmap — Feasibility Assessment
The roadmap (`new_agent_types_roadmap.md`) is a planning/architecture document,
not executable code. Its value rests on the accuracy of its codebase references
and the soundness of its blueprint. Both verified:
| Roadmap claim | Verification | Result |
|---------------|--------------|--------|
| Adapter framework `lib_py.agents` with `BaseAgentAdapter` | `lib_py/agents/{base.py,registry.py,sanitize.py,input_region.py,adapters/}` exist | ✓ |
| `BaseAgentAdapter` interface (name, own_key, ready_tokens, exit_key, delegate_agent_key, identity_cache_fields, input_prompt, input_rule_pattern, artifact_path, verify_artifact, purge_artifacts, spawn_spec, resume_spec, auth_ok, discover) | All symbols present in `base.py` at the cited lines | ✓ exact |
| `DiscoveryContext` used in template code | `class DiscoveryContext` at `base.py:15` | ✓ |
| Step 2: add `'newagent': NewAgentAdapter()` to `_ADAPTERS` | `registry.py` `_ADAPTERS` dict registers claude/agy/hermes/cline exactly this way | ✓ proven pattern |
| Step 3.1: herdr kind mapping `lib.sh:358-375` | kind dispatch at `lib.sh:357-371` (creator/planner/reviewer → kind) | ✓ (line off by ~4) |
| Step 3.3: "automatic via `agent_of_row()`" | `agent_of_row()` + `matches_session_name()` in `registry.py` | ✓ |
| Step 5.1: contract tests `test_agent_adapter_registry` / `test_adapter_required_properties` / `test_facts_bridge_eval_contract` | All three present in `test_a4_adapter_contract.py` (lines 28/58/79) | ✓ |
| Step 5.2: `tests/test_tier2_component.py` | File exists | ✓ |
**Feasibility verdict**: The 5-step blueprint (adapter class → registry →
`lib.sh` dispatch → herdr `--kind` → contract/lifecycle tests) is the correct
layering and is proven by the four existing adapters. The complexity matrix
(Tier 1 adapter ≈ 0.5 day; Tier 2 herdr-kind/lifecycle; Tier 3 TUI readiness)
is reasonable. The document is actionable as-is.
## 5. Findings (advisory, non-blocking)
### F-1 (hygiene): Roadmap file is untracked
`git status` lists `.agents/reports/new_agent_types_roadmap.md` as untracked.
Since it is a deliverable referenced by this job's output contract, it should
be `git add`-ed and committed (e.g. `docs(roadmap): new agent types extension
blueprint`) so the planning artifact persists. Content is fine; only tracking
state needs action.
### F-2 (verification gap): Full-suite green not independently confirmed
The roadmap §1.1 asserts "all 371 tests across 18 suites pass." This review
confirmed 98 unit tests across all touched files plus the adapter-contract
suite, but did not re-run the full integration suite within the time budget.
**Direction**: confirm the full `pytest tests/` is green in CI before merge;
no code change implied.
### F-3 (doc wording nit): `min_rows=0` is "disabled-by-default", not "removed"
Roadmap §1.1 says vertical constraints were "removed." Precisely, the guards
are `min_rows > 0 and ...`, so a positive `MAM_MIN_PANE_ROWS` env value
re-engages height checks. The doc slightly understates this opt-in
re-enablement. **Direction**: one-line wording fix ("disabled by default;
re-enabled by setting MAM_MIN_PANE_ROWS>0") for operator accuracy. Non-blocking.
## 6. Summary
The committed layout change correctly and consistently relaxes `min_cols` to
15 and defaults `min_rows` to 0 (disabled-by-default, re-engaged via env) with
matching, passing tests (98 verified green) and clean syntax. The new-agent
roadmap is a feasible, accurately-referenced blueprint grounded in the real
adapter framework (every cited file, symbol, and test verified to exist). The
three findings are commit-hygiene / verification-gap / wording nits that do
not affect correctness, operability, or feasibility, and require no redesign.
[VERDICT: PASS]
+1 -1
View File
@@ -429,7 +429,7 @@ except Exception:
split_dir="" split_dir=""
if [ -n "$sample_pane" ]; then if [ -n "$sample_pane" ]; then
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "") layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-40}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${split_dir:-right}" split_dir="${split_dir:-right}"
sample_pane="${split_target:-$sample_pane}" sample_pane="${split_target:-$sample_pane}"
fi fi
+9 -9
View File
@@ -70,8 +70,8 @@ def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
def compute_2xk_layout( def compute_2xk_layout(
data: Dict[str, Any], data: Dict[str, Any],
min_cols: int = 40, min_cols: int = 15,
min_rows: int = 20, min_rows: int = 0,
max_columns: Optional[int] = None, max_columns: Optional[int] = None,
default_anchor_id: Optional[str] = None default_anchor_id: Optional[str] = None
) -> LayoutDecision: ) -> LayoutDecision:
@@ -90,8 +90,8 @@ def compute_2xk_layout(
if len(panes) == 1: if len(panes) == 1:
p = panes[0] p = panes[0]
# In 2xK grid, 1 pane -> 2 panes: split down to create top and bottom rows # In 2xK grid, 1 pane -> 2 panes: split down to create top and bottom rows
# Check height overflow if dimensions known # Check height overflow only if min_rows > 0 is explicitly configured
if p.height > 0 and p.height // 2 < min_rows: if min_rows > 0 and p.height > 0 and p.height // 2 < min_rows:
# If height is too small for 2 rows, try splitting right if width allows # If height is too small for 2 rows, try splitting right if width allows
if p.width > 0 and p.width // 2 >= min_cols: 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") return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained")
@@ -153,8 +153,8 @@ def compute_2xk_layout(
if singleton_col is not None: if singleton_col is not None:
target_p = singleton_col[0] target_p = singleton_col[0]
# Check height # Check height only if min_rows > 0
if target_p.height > 0 and target_p.height // 2 < min_rows: if min_rows > 0 and 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="overflow", is_overflow=True, reason="singleton_height_overflow")
return LayoutDecision(target_pane_id=target_p.pane_id, direction="down", reason="fill_singleton_column") return LayoutDecision(target_pane_id=target_p.pane_id, direction="down", reason="fill_singleton_column")
@@ -176,7 +176,7 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
"""First *valid* int among the env vars in *names*, else `default`. """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 `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). 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 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 typo in an operator's shell must not take the whole layout call down (lib.sh
@@ -198,8 +198,8 @@ def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
def main(): def main():
parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction") 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-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=20)) 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")) 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("--sample-pane", type=str, default=None)
parser.add_argument("--json", action="store_true", help="Output full JSON decision") parser.add_argument("--json", action="store_true", help="Output full JSON decision")
+7 -6
View File
@@ -128,13 +128,14 @@
#default: 3 #default: 3
# SKS_EMPTY_GIVEUP=3 # SKS_EMPTY_GIVEUP=3
# Minimum columns a pane must retain after a vertical split (2xK layout engine). # Minimum columns a pane must retain after a horizontal/column split (2xK layout engine).
#default: 40 #default: 15
# MAM_MIN_PANE_COLS=40 # MAM_MIN_PANE_COLS=15
# Minimum rows a pane must retain after a horizontal split (2xK layout engine). # Minimum rows a pane must retain after a vertical split (2xK layout engine).
#default: 20 # Set to 0 to disable vertical row constraints (terminal scrollback handles height).
# MAM_MIN_PANE_ROWS=20 #default: 0
# MAM_MIN_PANE_ROWS=0
# Maximum number of columns a workspace may grow to before the engine reports # Maximum number of columns a workspace may grow to before the engine reports
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting). # 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
+66 -96
View File
@@ -287,7 +287,7 @@ split_dir=""
# Exact snippet from lib.sh:429-435 # Exact snippet from lib.sh:429-435
if [ -n "$sample_pane" ]; then if [ -n "$sample_pane" ]; then
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "") layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-40}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-15}}" --min-rows "${{MAM_MIN_PANE_ROWS:-0}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${{split_dir:-right}}" split_dir="${{split_dir:-right}}"
sample_pane="${{split_target:-$sample_pane}}" sample_pane="${{split_target:-$sample_pane}}"
fi fi
@@ -435,11 +435,11 @@ _ZERO_TRAP = {"result": {"panes": [
def test_j1_env_zero_min_cols_matches_flag_zero(): def test_j1_env_zero_min_cols_matches_flag_zero():
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 40 default.""" """J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 15 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0")) flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20"))
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained" assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"): for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {var: "0"}) == flag, var
def test_j1_env_zero_min_rows_matches_flag_zero(): def test_j1_env_zero_min_rows_matches_flag_zero():
@@ -452,8 +452,8 @@ def test_j1_env_zero_min_rows_matches_flag_zero():
def test_j1_nonzero_and_malformed_env_behaviour_unchanged(): def test_j1_nonzero_and_malformed_env_behaviour_unchanged():
"""Behaviour neutrality: non-zero env still applies, and a lone typo still """Behaviour neutrality: non-zero env still applies, and a lone typo still
lands on the documented default instead of crashing on a None comparison.""" lands on the documented default instead of crashing on a None comparison."""
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) == \ assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25", "MAM_MIN_PANE_ROWS": "20"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "25")) _run_layout(_ZERO_TRAP, ("--min-cols", "25", "--min-rows", "20"))
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP) assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP)
@@ -465,32 +465,41 @@ def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
Empty values already fell through (`if raw:`); this makes invalid values Empty values already fell through (`if raw:`); this makes invalid values
behave the same way. When every candidate is unusable, `default` still wins. behave the same way. When every candidate is unusable, `default` still wins.
""" """
good = _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) good = _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_PANE_COLS": "25"})
assert good["direction"] == "right" assert good["direction"] == "right"
# 별칭이 깨져 있어도 문서화된 변수가 적용된다 # 별칭이 깨져 있어도 문서화된 변수가 적용된다
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "25"}) == good "MAM_MIN_PANE_COLS": "25"}) == good
# 0 도 마찬가지 (J-1 과의 상호작용) # 0 도 마찬가지 (J-1 과의 상호작용)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "0"}) == \ "MAM_MIN_PANE_COLS": "0"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "0")) _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20"))
# 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존) # 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP) "MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)
def test_default_min_cols_is_40(): def test_default_min_cols_is_15_and_min_rows_is_0():
"""Verify compute_2xk_layout default min_cols is 40. """Verify compute_2xk_layout default min_cols is 15 and min_rows is 0.
With width 80 (width//2 = 40): With 1 pane of 54x23:
- min_cols=40 -> 40 >= 40 -> split right (new column). - min_rows=0 -> splits down without height overflow
- min_cols=60 -> 40 < 60 -> overflow. With 2 panes of 30x23 (width//2 = 15):
Default invocation (no min_cols passed) must split right. - min_cols=15 -> 15 >= 15 -> split right (new column).
- min_cols=40 -> 15 < 40 -> overflow.
Default invocation (no min_cols/min_rows passed) must split right for 30 cols.
""" """
# 1. 1 pane of 54x23 splits down (no height constraint)
p1_54x23 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
d1 = compute_2xk_layout(p1_54x23)
assert d1.direction == "down"
assert not d1.is_overflow
# 2. 2 panes with width 30 (30 // 2 = 15 == min_cols 15) -> splits right cleanly
payload = { payload = {
"result": { "result": {
"panes": [ "panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
] ]
} }
} }
@@ -500,118 +509,79 @@ def test_default_min_cols_is_40():
assert decision.reason == "new_column_right" assert decision.reason == "new_column_right"
def test_80_col_2_column_splitting_boundary(): def test_30_col_2_column_splitting_boundary():
"""Verify width >= 80 cols allows 2-column splitting with default min_cols=40, """Verify width >= 30 cols allows 2-column splitting with default min_cols=15,
while width < 80 (e.g. 79) triggers column_width_overflow. while width < 30 (e.g. 29) triggers column_width_overflow.
""" """
# 80 cols: 80 // 2 = 40 == min_cols(40) -> splits right # 30 cols: 30 // 2 = 15 == min_cols(15) -> splits right
payload_80 = { payload_30 = {
"result": { "result": {
"panes": [ "panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}},
] ]
} }
} }
d80 = compute_2xk_layout(payload_80) d30 = compute_2xk_layout(payload_30)
assert d80.direction == "right" assert d30.direction == "right"
assert not d80.is_overflow assert not d30.is_overflow
# 79 cols: 79 // 2 = 39 < min_cols(40) -> overflow # 29 cols: 29 // 2 = 14 < min_cols(15) -> overflow
payload_79 = { payload_29 = {
"result": { "result": {
"panes": [ "panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}}, {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}},
] ]
} }
} }
d79 = compute_2xk_layout(payload_79) d29 = compute_2xk_layout(payload_29)
assert d79.direction == "overflow" assert d29.direction == "overflow"
assert d79.is_overflow assert d29.is_overflow
assert d79.reason == "column_width_overflow" assert d29.reason == "column_width_overflow"
def test_90_col_single_workspace_multi_pane_tiling(): def test_54x23_compact_viewport_single_workspace_multi_pane_tiling():
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 90-col single workspace. """Verify complete 1 -> 2 -> 3 -> 4 pane tiling in standard 80x24 (54x23 content area) workspace.
- 1 pane (90x40): splits down to p1(90x20), p2(90x20) - 1 pane (54x23): splits down to p1(54x11), p2(54x11)
- 2 panes: splits right to start col 2 -> p3(45x40) - 2 panes: splits right (54//2 = 27 >= 15) to start col 2 -> p3(27x23)
- 3 panes: fills singleton col 2 down -> p4(45x20) - 3 panes: fills singleton col 2 down -> p4(27x11)
- 4 panes (2x2 grid): 5th agent overflows because 45 // 2 = 22 < 40 - 4 panes (2x2 grid): 5th agent overflows because 27 // 2 = 13 < 15
""" """
# 1 -> 2 # 1 -> 2 (splits down)
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 40}}]}} p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
d1 = compute_2xk_layout(p1_layout) d1 = compute_2xk_layout(p1_layout)
assert d1.direction == "down" assert d1.direction == "down"
assert d1.target_pane_id == "p1" assert d1.target_pane_id == "p1"
assert not d1.is_overflow
# 2 -> 3 # 2 -> 3 (splits right)
p2_layout = {"result": {"panes": [ p2_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 90, "height": 20}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 54, "height": 11}},
]}} ]}}
d2 = compute_2xk_layout(p2_layout) d2 = compute_2xk_layout(p2_layout)
assert d2.direction == "right" assert d2.direction == "right"
assert d2.target_pane_id == "p1" assert d2.target_pane_id == "p1"
assert not d2.is_overflow assert not d2.is_overflow
# 3 -> 4 # 3 -> 4 (fills singleton col 2 down)
p3_layout = {"result": {"panes": [ p3_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 40}}, {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}},
]}} ]}}
d3 = compute_2xk_layout(p3_layout) d3 = compute_2xk_layout(p3_layout)
assert d3.direction == "down" assert d3.direction == "down"
assert d3.target_pane_id == "p3" assert d3.target_pane_id == "p3"
assert not d3.is_overflow assert not d3.is_overflow
# 4 -> 5 (overflow to new workspace) # 4 -> 5 (overflow to new workspace because 27 // 2 = 13 < 15)
p4_layout = {"result": {"panes": [ p4_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 20}}, {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p4", "rect": {"x": 45, "y": 20, "width": 45, "height": 20}}, {"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 11}},
]}}
d4 = compute_2xk_layout(p4_layout)
assert d4.direction == "overflow"
assert d4.is_overflow
assert d4.reason == "column_width_overflow"
def test_100_col_single_workspace_multi_pane_tiling():
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 100-col single workspace."""
# 1 -> 2
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}]}}
d1 = compute_2xk_layout(p1_layout)
assert d1.direction == "down"
# 2 -> 3
p2_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 100, "height": 20}},
]}}
d2 = compute_2xk_layout(p2_layout)
assert d2.direction == "right"
assert not d2.is_overflow
# 3 -> 4
p3_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 40}},
]}}
d3 = compute_2xk_layout(p3_layout)
assert d3.direction == "down"
assert d3.target_pane_id == "p3"
assert not d3.is_overflow
# 4 -> 5 (overflow to new workspace)
p4_layout = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 20}},
{"pane_id": "p4", "rect": {"x": 50, "y": 20, "width": 50, "height": 20}},
]}} ]}}
d4 = compute_2xk_layout(p4_layout) d4 = compute_2xk_layout(p4_layout)
assert d4.direction == "overflow" assert d4.direction == "overflow"
+27 -30
View File
@@ -865,51 +865,48 @@ def test_lib_sh_new_session_passes_mam_ws_label(mam_sandbox):
# ============================================================================== # ==============================================================================
# FEATURE: 2xK Grid Layout Engine (min_cols=40 & multi-pane workspace tiling) # FEATURE: 2xK Grid Layout Engine (min_cols=15 & min_rows=0 multi-pane workspace tiling)
# ============================================================================== # ==============================================================================
def test_layout_default_min_cols_40_in_tier1(): def test_layout_default_min_cols_15_in_tier1():
"""Verify default min_cols=40 behavior across compute_2xk_layout in Tier 1 suite.""" """Verify default min_cols=15 behavior across compute_2xk_layout in Tier 1 suite."""
from lib_py.layout import compute_2xk_layout from lib_py.layout import compute_2xk_layout
# 1. 2 panes in 80 col width (80 // 2 = 40 == min_cols 40) -> splits right cleanly # 1. 2 panes in 30 col width (30 // 2 = 15 == min_cols 15) -> splits right cleanly
payload_80 = { payload_30 = {
"result": { "result": {
"panes": [ "panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}} {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}}
] ]
} }
} }
decision = compute_2xk_layout(payload_80) decision = compute_2xk_layout(payload_30)
assert decision.direction == "right" assert decision.direction == "right"
assert not decision.is_overflow assert not decision.is_overflow
assert decision.reason == "new_column_right" assert decision.reason == "new_column_right"
# 2. 2 panes in 79 col width (79 // 2 = 39 < min_cols 40) -> column_width_overflow # 2. 2 panes in 29 col width (29 // 2 = 14 < min_cols 15) -> column_width_overflow
payload_79 = { payload_29 = {
"result": { "result": {
"panes": [ "panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}}, {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}},
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}} {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}}
] ]
} }
} }
decision_overflow = compute_2xk_layout(payload_79) decision_overflow = compute_2xk_layout(payload_29)
assert decision_overflow.direction == "overflow" assert decision_overflow.direction == "overflow"
assert decision_overflow.is_overflow assert decision_overflow.is_overflow
assert decision_overflow.reason == "column_width_overflow" assert decision_overflow.reason == "column_width_overflow"
def test_layout_single_workspace_90_100_cols_tiling_tier1(): def test_layout_single_workspace_54x23_compact_tiling_tier1():
"""Verify 3-4 agents tiling in standard 90-100 col terminal windows within a single workspace.""" """Verify 3-4 agents tiling in standard 80x24 (54x23 content) terminal windows within a single workspace."""
from lib_py.layout import compute_2xk_layout from lib_py.layout import compute_2xk_layout
for total_w in [90, 100]: # Step 1: 1 pane -> 2 panes (split down without height constraint)
half_w = total_w // 2 p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}}
# Step 1: 1 pane -> 2 panes (split down)
p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 40}}]}}
d1 = compute_2xk_layout(p1) d1 = compute_2xk_layout(p1)
assert d1.direction == "down" assert d1.direction == "down"
assert d1.target_pane_id == "p1" assert d1.target_pane_id == "p1"
@@ -917,8 +914,8 @@ def test_layout_single_workspace_90_100_cols_tiling_tier1():
# Step 2: 2 panes -> 3 panes (split right to open 2nd column) # Step 2: 2 panes -> 3 panes (split right to open 2nd column)
p2 = {"result": {"panes": [ p2 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": total_w, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": total_w, "height": 20}} {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 54, "height": 11}}
]}} ]}}
d2 = compute_2xk_layout(p2) d2 = compute_2xk_layout(p2)
assert d2.direction == "right" assert d2.direction == "right"
@@ -927,21 +924,21 @@ def test_layout_single_workspace_90_100_cols_tiling_tier1():
# Step 3: 3 panes -> 4 panes (split singleton 2nd column down) # Step 3: 3 panes -> 4 panes (split singleton 2nd column down)
p3 = {"result": {"panes": [ p3 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 40}} {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}}
]}} ]}}
d3 = compute_2xk_layout(p3) d3 = compute_2xk_layout(p3)
assert d3.direction == "down" assert d3.direction == "down"
assert d3.target_pane_id == "p3" assert d3.target_pane_id == "p3"
assert not d3.is_overflow assert not d3.is_overflow
# Step 4: 4 panes (2x2 complete) -> 5th agent overflows to fresh workspace # Step 4: 4 panes (2x2 complete) -> 5th agent overflows to fresh workspace (27 // 2 = 13 < 15)
p4 = {"result": {"panes": [ p4 = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": half_w, "height": 20}}, {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": half_w, "height": 20}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}},
{"pane_id": "p3", "rect": {"x": half_w, "y": 0, "width": half_w, "height": 20}}, {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}},
{"pane_id": "p4", "rect": {"x": half_w, "y": 20, "width": half_w, "height": 20}} {"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 11}}
]}} ]}}
d4 = compute_2xk_layout(p4) d4 = compute_2xk_layout(p4)
assert d4.direction == "overflow" assert d4.direction == "overflow"