Skip to content

amux 0.2: tiled panes (split tree + vterm compositor) - #1

Merged
JSBtechnologies merged 2 commits into
mainfrom
feat/tiled-panes
Aug 29, 2026
Merged

amux 0.2: tiled panes (split tree + vterm compositor)#1
JSBtechnologies merged 2 commits into
mainfrom
feat/tiled-panes

Conversation

@JSBtechnologies

Copy link
Copy Markdown
Contributor

What

Adds tiled panes to amux — four agents in a 2×2 square, the deferred 0.1 feature. Implements the amux 0.2 tiling design doc.

Dual-mode run loop:

  • Passthrough (0.1 path, untouched) for a single pane or a zoomed pane — raw VT bytes straight to the terminal, perfect fidelity.
  • Tiled the moment a window holds 2+ visible panes — each pane drives a vterm::Term sized to its rect; every pane's ansi::Screen is composited into one master and Screen::diff writes only the changed bytes.

Windows (Ctrl+A c / 1-9 / n / p) and splits coexist: each window is its own split tree.

New crate dependency

vterm = { git = ".../vterm-rs", branch = "main" } — the emulator core (nativelite/vterm-rs 0.1.0), also new. Zero third-party deps holds (org crates only; dep guard passes). Version bumped 0.1.1 → 0.2.0.

Keys (all additive; every 0.1 key unchanged)

Key Action
Ctrl+A " split focused pane horizontal (stacked)
Ctrl+A % split focused pane vertical (side by side)
Ctrl+A + arrows or h/j/k/l move focus (geometric — intuitive on a 2×2)
Ctrl+A z zoom focused pane to full-screen passthrough
Ctrl+A x kill focused pane, re-tile to survivor

New modules (pure, unit-testable)

  • src/layout.rs — the split tree: split / close (collapse to sibling) / rects (reserves a 1-cell divider gutter) / geometric move_focus.
  • src/tile.rs — the compositor: blit each pane at its offset, draw dividers, highlight the focused edge, park the cursor in master coords.

How verified

python dev.py check green: dep guard OK, 13 lib unit tests (layout rects/focus/close/2×2, compositor cells/cursor/dividers) + 26 integration tests (all 0.1 tests + splits round-trip, two_by_two_grid_has_four_live_panes, focus routing, zoom, kill-retile, prefixed-arrow parsing incl. chunk-split) + doctests. cargo clippy --all-targets and cargo fmt --check clean. Reviewed both pure modules and the dual-mode loop by hand.

Fidelity boundaries (honest)

Tiling is the emulated path. Wide/CJK glyphs, sixel, and mouse reporting inside a tile are out of scope for 0.2 — zoom (Ctrl+A z) is the escape hatch back to raw passthrough. Stated in code comments and the vterm README.

Not in this PR

Proportional drag-resize (equal splits only), saved layouts, the deferred settings surface.

🤖 Generated with Claude Code

Add the pure engine for tiled panes, on the new vterm-rs org crate:

- Cargo: depend on nativelite/vterm-rs (org crate, zero third-party held),
  bump to 0.2.0.
- src/layout.rs: the split tree. A binary tree of panes and H/V splits
  resolving to a rect per leaf, reserving a 1-cell divider gutter between
  siblings. Equal splits (MVP); geometric focus movement so a 2x2 grid does
  the intuitive thing regardless of nesting; close-collapses-to-sibling.
- src/tile.rs: the compositor. Blit each pane's emulated ansi::Screen into a
  master Screen at its offset, draw the divider gutters, highlight the focused
  pane's edges, and park the cursor at the focused pane in master coords.
- src/input.rs: extend Action additively (0.1 keys unchanged): SplitH ("),
  SplitV (%), MoveFocus (h/j/k/l and prefixed arrows via a small ESC[ state
  machine), Zoom (z).

All pure, unit-tested (layout rects/focus/close, compositor cells/cursor).
Wire the split tree and compositor into a second rendering mode beside the
0.1 passthrough loop:

- Windows now hold a split tree of panes (Ctrl+A c/n/p/1-9 still switch
  windows; each window can itself be tiled). A single or zoomed pane keeps the
  0.1 passthrough path (raw bytes -> terminal, perfect fidelity); two+ visible
  panes render tiled: feed each pane's vterm::Term, compose to a master
  ansi::Screen, and master.diff(&prev) writes only changed bytes.
- Ctrl+A "/% split the focused pane, h/j/k/l or arrows move focus, z zooms
  (full-screen passthrough escape hatch), x kills the focused pane and
  re-tiles to the survivor synchronously so input never routes to a corpse.
- Per-pane resize: on split/close/zoom/terminal-resize, recompute rects and
  push each to its pty and Term.
- bar: keys-help line notes the split/focus/zoom keys.

Tests: unit-test the new Actions (incl. prefixed-arrow state machine, chunk
splits); e2e (pty-driven) — split makes a second live tile with both shells
round-tripping, a 2x2 has four live panes (all markers appear), focus
movement re-routes input, zoom toggles, kill re-tiles to a live survivor.
python dev.py check green (13 lib + 26 integration + doctests).
@JSBtechnologies
JSBtechnologies merged commit de3c579 into main Aug 29, 2026
1 check failed
@JSBtechnologies
JSBtechnologies deleted the feat/tiled-panes branch August 29, 2026 04:28
JSBtechnologies added a commit that referenced this pull request Aug 30, 2026
…(0.8.1)

A ctl spawn (new window) and --here split sized the worker's pty to a rough
estimate but never resized the window, so the agent painted short (content at
top, blank below) until a manual terminal resize. Both paths now call
resize_window right after spawning, matching the interactive split handlers.
Fixes founder repaint reports #1 (new-window spawn) and #3 (--here didn't push
to bottom).

Verified: python dev.py check green (119 lib + 46 integ).
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
…eview #1, #6]

Three changes, plus the seam the Windows work lands in.

#1a — `caller_privileged` returned `true` for a caller with no token. The gate
was inverted: holding NO credential granted strictly more than holding a
worker's. `caller` is derived from the capability token and never
self-reported, so `None` means exactly "unauthenticated" and must be the least
trusted state. A token resolving to no live pane is stale or forged, and gets
the same treatment.

#1c — the bad-request audit entry logged `truncate(line, 80)` of the raw
request. `build_request` emits `{"caller":N,"token":"` — a 21-character prefix
— so 59 of a 64-character token went into a log that `ctl audit` will hand to a
reader holding no credential. It now records the shape and the parse error,
which is what debugging needs and is all it needs.

#6 — the background pane drain was an unbounded `while let`, unlike the focused
path's `for i in 0..DRAIN_READS_PER_TICK` right above it. One noisy background
agent could hold the event loop for as long as it kept producing, starving
keystrokes, signal handling and ctl. A fleet makes that likely, not theoretical.
Now bounded identically.

Windows prep — `spawn_watchdog` is gated to unix. On Windows the watchdog is
worse than redundant: it would spawn a second amux that cannot signal a process
group and would block on its pipe forever. The comment marks where the Job
Object attaches, since the pane set is already known to have changed there.

NOT VERIFIED BY TEST, and worth stating rather than implying otherwise: I wrote
an integration test for the privilege inversion and it passed with the fix
REVERTED, so it was asserting nothing — the elevated spawn is refused earlier,
by policy or the sanitizer, and never reaches the privilege gate. Removed rather
than kept as false coverage. Review finding #16 already noted this function has
zero tests; it still does. A real one has to drive a genuinely privilege-gated
path (scope, or the mode cap in a session whose policy would otherwise allow it).

macOS 26.6.2: ./dev.py check green — 306 tests, 0 failures, clippy clean.
Type-checks for x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
JSBtechnologies added a commit that referenced this pull request Sep 4, 2026
…eview #1]

The last of the privilege chain, and it is a behaviour change — flagged as such.

`--mode` was bypassed outright for a "privileged" caller: `Some(req) if
privileged => req`. The reading was that the root pane is the human, and the
human may direct their own session. Two things break it.

`-n N` and `--grid` give every pane no parent, so EVERY pane in a mass-spawned
session was classified as the operator. Any agent in one could ask for `skip` — a
full permission bypass, --dangerously-skip-permissions — in a session the human
had deliberately launched as `plan`. Since the human issues ctl from inside a
pane too, there is no way to tell "you" from "an agent running where you launched
it": tree position was never a proxy for authority.

And amux's own documentation calls --trust "the mode spawned agents run in, and
the ceiling they are capped at". A ceiling a pane can exceed is not a ceiling.
The README simultaneously promised that the root pane could elevate above it.
Both cannot hold; the ceiling is the one worth keeping.

So the bypass is gone and the policy caps every caller. `--mode` still
DE-ESCALATES freely — asking for less is always safe, and that is the useful half
— it simply cannot escalate. Raising the posture is done at launch with --trust,
where it is one visible deliberate choice rather than something a pane can
request mid-session.

The cap logic is now a pure `effective_mode(requested, policy)`, tested both ways:
skip is refused down to plan with a note, and de-escalation passes untouched.

README and the CLI help are corrected rather than left contradicting the code,
with a short note on why the root-pane carve-out went — a future reader will
otherwise re-add it for the same plausible reason it existed.

Trade-off, plainly: you can no longer spawn a teammate above the session policy
mid-run. If that turns out to matter, the right shape is an explicit operator
credential the amux process holds and never injects into any pane env — not an
inference from where a pane sits in the tree.

macOS 26.6.2: ./dev.py check green — 324 tests, 0 failures, clippy clean.
Type-checks for x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant