Make the message-input bar part of the F5 resize cycle so its height is adjustable without a sandbox (multi-line/wrapped inputs are now readable), and let chat/clergy borrow height from the compose box when no terminal is present. Add Option C masked sudo prompt that feeds `sudo -S` over stdin to install/start Docker — the password never reaches chat, the PTY, or outbound frames, and Docker Desktop on Linux is detected so no sudo is requested. Fix a freeze where clicking the compose box entered layout-edit mode and silently swallowed every keystroke: clicking the input bar no longer enters edit mode, and typing any printable char now drops out of edit mode and types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.7 KiB
Spec: BSP pane layout (resize every pane in both dimensions)
Status: implemented · Branch: feat/bsp-pane-layout
Implementation note: the shipped tree uses two typed splits (
VSplit/HSplit) rather than a single uniformSplit{axis, ratio}. This keeps the roster a fixed-cell column (not a percentage) and lets the type system encode "chat-over-terminal" vs "column-beside-roster" directly. The sections below describe the original uniform model; the typed shape is called out inline where they differ.
Motivation
Today the window layout is two scalars over a fixed 3-pane arrangement
(layout::Layout { pty_pct, roster_width, zoom }):
pty_pct— sandbox terminal's share of body height (chat takes the rest).roster_width— roster column width in cells.
Consequences the user hit:
- No sandbox → almost nothing adjustable.
body_areasonly creates the chat/terminal vertical splitif app.sandbox.is_some(). Without a sandbox the chat owns the whole body, so the only live dimension is the roster width. The↑/↓keys still callgrow_pty/shrink_pty, but with no terminal pane to trade height against, nothing moves. - Each pane is locked to one axis. The terminal only changes height (always full width); the roster only changes width (always full height). You can't, e.g., make the terminal narrower or give the roster its own height.
Goal: let the user resize each pane in both width and height, using the
standard BSP (binary space-partition) pane-tree model (tmux/i3-style).
Researched in chat: ratatui core has no interactive-resize/BSP primitive (only
the Cassowary/kasuari solver + the "store-percent, mutate, re-solve" pattern);
the ratkit crate's ResizableGrid proves the BSP-tree approach. We roll a
small in-repo tree (option 2 from the research) so the PTY-resync integration
stays first-class and we take no new dependency.
Model
Replace the two scalars with a binary tree. Leaves are the three semantic panes; internal nodes are splits with an axis + ratio.
Original uniform sketch:
pub enum PaneKind { Chat, Roster, Terminal }
pub enum Axis { Horizontal, Vertical } // H: side-by-side (|), V: stacked (—)
pub enum Node {
Leaf(PaneKind),
Split { axis: Axis, ratio: u16, a: Box<Node>, b: Box<Node> },
}
As shipped (hh/src/layout.rs) — typed splits, reusing app::Pane as the
leaf type so it can be (de)serialized into presets:
pub enum Node {
Leaf(Pane), // Pane = Chat|Roster|Terminal
VSplit { top_pct: u16, top: Box<Node>, bottom: Box<Node> }, // chat over terminal, by %
HSplit { right_cells: u16, left: Box<Node>, right: Box<Node> },// column | roster, by cells
}
pub struct Layout { root: Node, pub zoom: Zoom } // Zoom: Normal | Term | Chat
The roster width lives as HSplit.right_cells (0 = hidden); there's no separate
roster_seed — the active theme seeds it once via Layout::set_roster_width.
Default arrangement (reproduces today's look)
With a sandbox present (typed shape as shipped):
HSplit(right_cells=22, // left column | roster (right, full height)
left = VSplit(top_pct=45, // chat (top) / terminal (bottom)
top = Leaf(Chat),
bottom = Leaf(Terminal)),
right = Leaf(Roster))
The tree is static; the terminal and roster are pruned at paint time
rather than rebuilt. fill() skips the VSplit when no sandbox is up (chat
fills the left column) and skips the HSplit divider when right_cells == 0
(roster hidden). So /sbx launch/stop and hiding the roster need no rebuild —
the same tree just paints fewer leaves. top_pct is clamped to [10, 80];
right_cells to [0, 60].
Geometry (single source of truth)
layout.rs owns recursive area computation; ui.rs::body_areas becomes a thin
wrapper so draw and pane_at keep sharing one geometry.
As shipped, regions just returns the visible leaves (no Divider struct yet —
that's deferred to phase 5), and takes has_terminal so it can prune:
pub fn regions(&self, body: Rect, has_terminal: bool) -> Vec<(Pane, Rect)>;
pub fn rect_of(&self, body: Rect, pane: Pane, has_terminal: bool) -> Option<Rect>;
ui.rs::body_areas is now a thin wrapper that fans regions out into its
{chat, roster, sbx} rects, so draw and pane_at share one geometry.
Zoom::Term/Zoom::Chat short-circuit regions (one pane fills the body; the
other leaves are omitted).
Resize semantics (keyboard)
A pane is selected via F5 (cycle over present_panes) or mouse click
(pane_at). Then arrows resize the split that bounds it on the matching axis:
↑/↓→ theVSplit(chat/terminal height). Only meaningful with a sandbox up and a non-roster pane selected — otherwiseNoAxisHere.←/→→ theHSplit(rosterright_cells). Growing the roster widens it; growing a left-column pane narrows it.
This makes every pane adjustable on both axes wherever such a split exists. If
none does (e.g. only Chat+Roster, press ↑), emit a one-line hint instead of
silently doing nothing — fixes consequence (1).
pub fn resize_focused(&mut self, pane: Pane, dir: Dir, has_terminal: bool) -> Resize;
// Resize::Moved | ::NoAxisHere (caller turns the latter into a sys hint)
The step size is fixed in layout.rs (PCT_STEP = 4, CELL_STEP = 2), not a
caller-supplied step.
PTY integration (the critical wrinkle)
Today sbx_dims(term_w, term_h, pty_pct) derives the PTY grid from the height
scalar and assumes full width. In the tree model the terminal can be any
rect, so the grid must come from the Terminal leaf's actual Rect:
As shipped it returns a concrete grid (falling back to the full body if no
Terminal leaf is laid out), rather than an Option:
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> (u16, u16) {
let body = Rect { x: 0, y: 1, width: term_w, height: term_h.saturating_sub(4) };
let r = layout.rect_of(body, Pane::Terminal, true).unwrap_or(body);
(r.height.saturating_sub(2).max(1), r.width.saturating_sub(2).max(1))
}
Every existing sbx_dims(...) call site (run loop, the two /sbx
launch/restore command handlers) switches to sbx_grid, and announced_dims = None still forces a re-sync after any resize — so the shared PTY rebroadcasts
new dims to the room exactly as before. This now also lets terminal width
changes propagate (any divider move sets announced_dims = None), which the
old height-only code couldn't express.
Persistence (presets)
/layout save|load|list|rm|reset keep working; the on-disk TOML now serializes
the tree instead of two scalars (layouts/<slug>.toml). Old single-scalar
preset files are not migrated (presets are throwaway; reset rebuilds default).
serde derives on Node/Zoom/Pane handle (de)serialization (Pane gained
Serialize, Deserialize in app.rs for this).
Out of scope (phase 5, optional, follow-up)
- Mouse-drag dividers. Not scaffolded yet —
regionsreturns only leaves; a follow-up adds aDivider/hit_thresholdreturn and drag handling (hover highlight,dragging_split). Keyboard resize is the phase-1..4 deliverable. - Arbitrary user-created splits / moving a pane to a new position. The tree supports it, but the UI only exposes the three named panes for now.
Work plan
- ✅ layout.rs — typed tree (
VSplit/HSplit), default builder,regions/rect_of,resize_focused, clamps, zoom,describe, serde, presets. 11 unit tests (geometry sums to body, resize honors clamps, no-axis case, serde round-trip, terminal/roster prune). - ✅ ui.rs —
body_areasis now a thin wrapper overregions;pane_atshares it;edit_decormarker on the focused pane. - ✅ app.rs —
sbx_gridreplacessbx_dims; key handler resizes both axes viaresize_focused(+ hint onNoAxisHere);cycle_focusoverpresent_panes; F4 zoom +announced_dimsre-sync unchanged. - ✅ docs — in-app
/helpLAYOUT cluster + README "Window layout" updated to describe both-axis resize and the no-axis hint; this spec reconciled. - (optional, not done) mouse-drag dividers.
Acceptance
cargo testgreen;cargo buildwarning-free.- No sandbox: selecting Chat and pressing
←/→resizes vs. roster;↑/↓prints the "no pane to trade height with — launch a sandbox" hint. - Sandbox up: Chat/Terminal resize in both axes; roster width still works; terminal width changes now re-sync the PTY grid to the room.
/layout save+/layout loadround-trips the new tree.- F4 fullscreen +
Ctrl+Rreconnect re-announce unchanged.